diff --git a/README.md b/README.md index 7d2057c675..5c53b05f5a 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ This command combines query capabilities with command execution, allowing you to The command uses the same query flags as the 'find' command to select packages, then executes the specified subcommand for each matched package. Allowed subcommands: -build, check, changelog, clean, format, install, lint, test, uninstall, update +build, check, changelog, clean, format, install, lint, requires, test, uninstall, update ### `elastic-package format` @@ -506,6 +506,26 @@ _Context: package_ Generate a benchmark report comparing local results against ones from another benchmark run. +### `elastic-package requires` + +_Context: package_ + +Manage requires dependencies for integration packages (requires.input and requires.content in manifest.yml). + +Use "requires update" to bump requires.input and requires.content versions from the package registry, +respecting the integration package Kibana version constraint. + +### `elastic-package requires update` + +_Context: package_ + +Update requires.input and requires.content pins to the latest registry versions compatible with this package's Kibana constraint. + +By default manifest.yml is updated. Use --dry-run to report available bumps without writing the manifest. +Version pins must be exact semver versions (constraints such as ^0.3.0 are not accepted). + +When a newer dependency exists but requires a higher Kibana version than this package allows, a warning is printed suggesting to bump conditions.kibana.version on the integration package. + ### `elastic-package service` _Context: package_ diff --git a/cmd/foreach.go b/cmd/foreach.go index 10e1a971dd..ceb1abd1fb 100644 --- a/cmd/foreach.go +++ b/cmd/foreach.go @@ -34,6 +34,7 @@ func getAllowedSubCommands() []string { "format", "install", "lint", + "requires", "test", "uninstall", "update", diff --git a/cmd/requires.go b/cmd/requires.go new file mode 100644 index 0000000000..1b878be836 --- /dev/null +++ b/cmd/requires.go @@ -0,0 +1,221 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package cmd + +import ( + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/olekukonko/tablewriter" + "github.com/olekukonko/tablewriter/renderer" + "github.com/spf13/cobra" + + "github.com/elastic/elastic-package/internal/cobraext" + "github.com/elastic/elastic-package/internal/formatter" + "github.com/elastic/elastic-package/internal/install" + "github.com/elastic/elastic-package/internal/logger" + "github.com/elastic/elastic-package/internal/packages" + "github.com/elastic/elastic-package/internal/registry" + "github.com/elastic/elastic-package/internal/requiresupdates" + "github.com/elastic/elastic-package/internal/stack" +) + +const ( + requiresLongDescription = `Manage requires dependencies for integration packages (requires.input and requires.content in manifest.yml). + +Use "requires update" to bump requires.input and requires.content versions from the package registry, +respecting the integration package Kibana version constraint.` + + requiresUpdateLongDescription = `Update requires.input and requires.content pins to the latest registry versions compatible with this package's Kibana constraint. + +By default manifest.yml is updated. Use --dry-run to report available bumps without writing the manifest. +Version pins must be exact semver versions (constraints such as ^0.3.0 are not accepted). + +When a newer dependency exists but requires a higher Kibana version than this package allows, a warning is printed suggesting to bump conditions.kibana.version on the integration package.` +) + +func setupRequiresCommand() *cobraext.Command { + updateCmd := &cobra.Command{ + Use: "update", + Short: "Update requires.input and requires.content versions from the registry", + Long: requiresUpdateLongDescription, + Args: cobra.NoArgs, + RunE: requiresUpdateCommandAction, + } + updateCmd.Flags().Bool(cobraext.RequiresDryRunFlagName, false, cobraext.RequiresDryRunFlagDescription) + updateCmd.Flags().String(cobraext.RequiresFormatFlagName, requiresFormatTable, fmt.Sprintf(cobraext.RequiresFormatFlagDescription, strings.Join(requiresFormatChoices, "|"))) + updateCmd.Flags().Bool(cobraext.RequiresPrereleaseFlagName, false, cobraext.RequiresPrereleaseFlagDescription) + + cmd := &cobra.Command{ + Use: "requires", + Short: "Manage requires dependencies for integration packages", + Long: requiresLongDescription, + } + cmd.AddCommand(updateCmd) + cmd.PersistentFlags().StringP(cobraext.ProfileFlagName, "p", "", fmt.Sprintf(cobraext.ProfileFlagDescription, install.ProfileNameEnvVar)) + + return cobraext.NewCommand(cmd, cobraext.ContextPackage) +} + +const ( + requiresFormatTable = "table" + requiresFormatJSON = "json" +) + +var requiresFormatChoices = []string{requiresFormatTable, requiresFormatJSON} + +func requiresUpdateCommandAction(cmd *cobra.Command, _ []string) error { + dryRun, err := cmd.Flags().GetBool(cobraext.RequiresDryRunFlagName) + if err != nil { + return cobraext.FlagParsingError(err, cobraext.RequiresDryRunFlagName) + } + format, err := cmd.Flags().GetString(cobraext.RequiresFormatFlagName) + if err != nil { + return cobraext.FlagParsingError(err, cobraext.RequiresFormatFlagName) + } + if !slices.Contains(requiresFormatChoices, format) { + return fmt.Errorf("unsupported format %q, supported formats: %s", format, strings.Join(requiresFormatChoices, ", ")) + } + prerelease, err := cmd.Flags().GetBool(cobraext.RequiresPrereleaseFlagName) + if err != nil { + return cobraext.FlagParsingError(err, cobraext.RequiresPrereleaseFlagName) + } + + packageRoot, err := packages.MustFindPackageRoot() + if err != nil { + return fmt.Errorf("locating package root failed: %w", err) + } + + prof, err := cobraext.GetProfileFlag(cmd) + if err != nil { + return err + } + + appConfig, err := install.Configuration() + if err != nil { + return fmt.Errorf("loading configuration failed: %w", err) + } + + baseURL := stack.PackageRegistryBaseURL(prof, appConfig) + eprClient, err := registry.NewClient(baseURL, stack.RegistryClientOptions(baseURL, prof)...) + if err != nil { + return fmt.Errorf("creating package registry client failed: %w", err) + } + logger.Debugf("using package registry: %s", baseURL) + + result, err := requiresupdates.Resolve(requiresupdates.Options{ + PackageRoot: packageRoot, + RegistryClient: eprClient, + Prerelease: prerelease, + }) + if err != nil { + return err + } + + applied := false + hasBumps := slices.ContainsFunc(result.Proposals, func(p requiresupdates.UpdateProposal) bool { + return p.Proposed != "" + }) + if !dryRun && hasBumps { + manifestPath := filepath.Join(packageRoot, packages.PackageManifestFile) + manifestBytes, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("reading manifest file failed: %w", err) + } + manifestBytes, err = requiresupdates.Apply(manifestBytes, result.Proposals) + if err != nil { + return err + } + logger.Debugf("writing updated manifest: %s", manifestPath) + if err := os.WriteFile(manifestPath, manifestBytes, 0o644); err != nil { + return fmt.Errorf("writing manifest file failed: %w", err) + } + applied = true + } + + for _, p := range result.Proposals { + if p.Warning != "" { + logger.Warn(p.Warning) + } + } + + if err := printRequiresUpdateResult(result, os.Stdout, format); err != nil { + return err + } + + if format == requiresFormatJSON { + return nil + } + + if dryRun && hasBumps { + cmd.Println("Dry run: manifest.yml was not modified") + } else if applied { + cmd.Println("Updated manifest.yml") + } else if len(result.Proposals) == 0 && result.SkipReason == "" { + cmd.Println("No dependencies to update") + } + + return nil +} + +func printRequiresUpdateResult(result *requiresupdates.Result, w io.Writer, format string) error { + if result == nil { + return nil + } + switch format { + case requiresFormatJSON: + data, err := formatter.NewJSONFormatter().Encode(result) + if err != nil { + return err + } + _, err = fmt.Fprintln(w, string(data)) + return err + case requiresFormatTable: + if result.Package != "" { + bold.Fprint(w, "Package: ") //nolint:errcheck + fmt.Fprintln(w, result.Package) //nolint:errcheck + } + if result.CodeOwner != "" { + bold.Fprint(w, "Code owner: ") //nolint:errcheck + fmt.Fprintln(w, result.CodeOwner) //nolint:errcheck + } + if result.SkipReason != "" { + fmt.Fprintln(w, result.SkipReason) //nolint:errcheck + return nil + } + if len(result.Proposals) == 0 { + return nil + } + bold.Fprintln(w, "Requires updates:") //nolint:errcheck + table := tablewriter.NewTable(w, + tablewriter.WithRenderer(renderer.NewColorized(defaultColorizedConfig())), + tablewriter.WithConfig(defaultTableConfig), + ) + table.Header([]string{"Kind", "Dependency", "Current", "Proposed", "Kibana", "Warning"}) + for _, p := range result.Proposals { + proposed := p.Proposed + if proposed == "" { + proposed = "-" + } + if err := table.Append([]string{ + string(p.Kind), + p.Package, + p.Current, + proposed, + p.KibanaConstraint, + p.Warning, + }); err != nil { + return fmt.Errorf("populating requires update table: %w", err) + } + } + return table.Render() + default: + return fmt.Errorf("unsupported format %q", format) + } +} diff --git a/cmd/root.go b/cmd/root.go index ea21ad2066..e405c3ad93 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -34,6 +34,7 @@ var commands = []*cobraext.Command{ setupLintCommand(), setupModifyCommand(), setupProfilesCommand(), + setupRequiresCommand(), setupReportsCommand(), setupServiceCommand(), setupStackCommand(), diff --git a/docs/howto/dependency_management.md b/docs/howto/dependency_management.md index 522e963807..55e21942f4 100644 --- a/docs/howto/dependency_management.md +++ b/docs/howto/dependency_management.md @@ -15,7 +15,7 @@ Elastic Packages support two kinds of build-time dependency: - **Field dependencies** — import field definitions from external schemas (e.g. ECS) using `_dev/build/build.yml`. Resolved from Git references and cached locally. -- **Package dependencies** — composable (integration) packages can depend on input and content packages +- **Package dependencies** — integration packages with `requires` can depend on input and content packages declared under `requires` in `manifest.yml`. **Input package** dependencies are resolved at build time by downloading from the package registry. **Content package** dependencies are resolved at runtime by Fleet. @@ -97,9 +97,9 @@ and use a following field definition: external: ecs ``` -## Composable packages and the package registry +## Integrations with required packages and the package registry -Composable (integration) packages can also depend on input or content packages by declaring them under +Integration packages can depend on input or content packages by declaring them under `requires` in `manifest.yml`. Depending on the package type, dependencies are resolved differently: **input package** dependencies are fetched at build time; **content package** dependencies are resolved at runtime by Fleet. @@ -114,7 +114,7 @@ requires: This type of dependency is resolved at **build time** by downloading the required input package from the **package registry**. During `elastic-package build`, elastic-package fetches those packages and updates the built integration: it bundles agent templates (policy and data stream), -merges variable definitions from the input packages into the composable manifest, adds data +merges variable definitions from the input packages into the integration manifest, adds data stream field definitions where configured, and rewrites `package:` references on inputs and streams to the concrete input types Fleet needs. Fleet still merges policy-specific values at policy creation time. @@ -127,16 +127,61 @@ package dependencies are fetched from the configured package registry URL For details on using a local or custom registry when the required input packages are still under development, see [HOWTO: Use a local or custom package registry](./local_package_registry.md). -### Testing composable packages with source overrides +### Updating `requires` pins from the package registry -When running `elastic-package test` on a composable integration whose required input packages +Integration packages with `requires` pin input and content dependencies in +`manifest.yml`. Use `elastic-package requires update` to bump those pins to the latest +versions published in the package registry that are compatible with this package's +`conditions.kibana.version` constraint. + +The command queries the same registry URL used by `elastic-package build`: `stack.epr.base_url` +in the active profile, then `package_registry.base_url` in `~/.elastic-package/config.yml`, +defaulting to `https://epr.elastic.co`. To point the command at a local or custom registry, +see [HOWTO: Use a local or custom package registry](./local_package_registry.md). + +By default the command writes updated versions to `manifest.yml`. Use `--dry-run` to preview +bumps without modifying the file. + +```bash +# Update requires pins for the package in the current directory +elastic-package requires update + +# Preview changes +elastic-package requires update --dry-run + +# Machine-readable output for automation (includes package name and owner.github for CI grouping) +elastic-package requires update --dry-run --format json +``` + +`requires.content` pins are always written as exact semver versions (for example `"0.4.0"`). +Constraint-style pins on content dependencies are normalized to an exact version on update. + +JSON output includes `package`, `codeowner` (from `owner.github` in `manifest.yml`), and `proposals` +with each dependency bump. Use `codeowner` to group batch PRs in CI. Packages skipped because they +are not applicable (for example, not an integration or no `requires` block) produce no JSON on stdout; +an info log is written instead. + +When a newer dependency revision exists but its `conditions.kibana.version` does not overlap +with this package's `conditions.kibana.version` constraint, the command prints a warning suggesting to bump +`conditions.kibana.version` on the integration package. It does not change that field +automatically. + +To refresh many integration packages in a repository (for example from a scheduled CI job): + +```bash +elastic-package foreach --type integration requires update +``` + +### Testing integrations with requires using source overrides + +When running `elastic-package test` on an integration with `requires` whose required input packages are not yet published to the registry, you can point each test runner at a local copy of the input package using the `requires` key in `_dev/test/config.yml`. Each entry in the `requires` list uses one of two forms: - **`source`** — a path to a local package directory or `.zip` file. Relative paths are - resolved relative to the composable package root. The package name is read from the + resolved relative to the integration package root. The package name is read from the `manifest.yml` at that path. - **`package` + `version`** — forces a specific version to be fetched from the registry (useful in CI where the package is already published and you want to pin a version). @@ -148,7 +193,7 @@ The `requires` key is supported under any test runner block: `policy`, `system`, in more than one block, the resolved absolute paths must be identical. ```yaml -# _dev/test/config.yml — composable integration package +# _dev/test/config.yml — integration with requires policy: requires: - source: "../my_input_pkg" # local directory, relative to this package root @@ -176,7 +221,7 @@ Some repositories share agent templates using **link files** (files ending in `. point at shared content). During `elastic-package build`, linked content is copied into the build output under the **target** path (the link filename without the `.link` suffix). -Composable bundling (`requires.input`) runs **after** linked files are materialized in the +`requires.input` bundling runs **after** linked files are materialized in the build directory. In `manifest.yml`, always set `template_path` / `template_paths` to those **materialized** names (for example `owned.hbs`), **not** the stub name (`owned.hbs.link`). Fleet and the builder resolve templates by the names declared in the manifest; the `.link` diff --git a/docs/howto/local_package_registry.md b/docs/howto/local_package_registry.md index ca5c2acc19..f3aff354e6 100644 --- a/docs/howto/local_package_registry.md +++ b/docs/howto/local_package_registry.md @@ -1,8 +1,8 @@ -# HOWTO: Use a local or custom package registry for composable integrations +# HOWTO: Use a local or custom package registry for integrations with requires ## Overview -Composable (integration) packages can declare required input packages in their `manifest.yml` +Integration packages with `requires` can declare required input packages in their `manifest.yml` under `requires.input`. When you run `elastic-package build` or `elastic-package install`, elastic-package resolves those dependencies by downloading them from the **package registry**. By default it uses the production registry at `https://epr.elastic.co`. @@ -122,7 +122,7 @@ package_registry: ### URL resolution reference -**For `elastic-package build`** (profile, then global config): +**For `elastic-package build` and `elastic-package requires update`** (profile, then global config): | Priority | Setting | | -------- | ------- | @@ -161,7 +161,7 @@ For more details on profiles, see the | Goal | Configuration | | ---- | ------------- | -| Override registry for `build` | `stack.epr.base_url` in the active profile `config.yml` (or `package_registry.base_url` in `~/.elastic-package/config.yml`) | +| Override registry for `build` and `requires update` | `stack.epr.base_url` in the active profile `config.yml` (or `package_registry.base_url` in `~/.elastic-package/config.yml`) | | Override registry for `test` / `benchmark` / `status` | `package_registry.base_url` in `~/.elastic-package/config.yml` | | Override registry for `install` and stack commands | `stack.epr.base_url` in the active profile `config.yml` | | Override proxy target for the stack's registry container | `stack.epr.proxy_to` in the active profile `config.yml` | diff --git a/docs/howto/policy_testing.md b/docs/howto/policy_testing.md index 0415f1509f..a46c3b0012 100644 --- a/docs/howto/policy_testing.md +++ b/docs/howto/policy_testing.md @@ -59,7 +59,7 @@ policy: The `skip` key skips all policy tests with a mandatory reason and optional issue link. -The `requires` key is used for **composable integration packages** that declare +The `requires` key is used for **integration packages with `requires`** that declare `requires.input` in their `manifest.yml`. It tells the policy test runner which local source path (or registry version) to use for each required input package when bundling the integration before running the tests. Without this, the runner fetches dependencies from the @@ -69,8 +69,8 @@ configured package registry. - Use `package` + `version` to pin a specific registry version. - `source` and `package`/`version` are mutually exclusive in the same entry. -For full details on composable packages and source overrides, see -[HOWTO: Enable dependency management](./dependency_management.md#testing-composable-packages-with-source-overrides). +For full details on integrations with requires and source overrides, see +[HOWTO: Enable dependency management](./dependency_management.md#testing-integrations-with-requires-using-source-overrides). ### Defining the configuration of the policy diff --git a/internal/cobraext/flags.go b/internal/cobraext/flags.go index e8c93e1348..2065fc197a 100644 --- a/internal/cobraext/flags.go +++ b/internal/cobraext/flags.go @@ -271,6 +271,13 @@ const ( StatusFormatFlagName = "format" StatusFormatFlagDescription = "output format (\"%s\")" + RequiresDryRunFlagName = "dry-run" + RequiresDryRunFlagDescription = "report dependency bumps without writing manifest.yml" + RequiresFormatFlagName = "format" + RequiresFormatFlagDescription = "output format (\"%s\")" + RequiresPrereleaseFlagName = "prerelease" + RequiresPrereleaseFlagDescription = "include pre-release versions when searching the registry (by default only stable versions are considered)" + TestCoverageFlagName = "test-coverage" TestCoverageFlagDescription = "enable test coverage reports" diff --git a/internal/formatter/json.go b/internal/formatter/json.go index 6b4d4d7d1b..05a82690f0 100644 --- a/internal/formatter/json.go +++ b/internal/formatter/json.go @@ -19,6 +19,17 @@ type JSONFormatter interface { Encode(doc any) ([]byte, error) } +// NewJSONFormatter returns a JSONFormatter that encodes JSON without HTML escaping +// and with consistent indentation. Use this for non-spec content (e.g. CLI output). +// For package-spec content whose format varies by spec version, use JSONFormatterBuilder. +func NewJSONFormatter() JSONFormatter { + return &jsonFormatter{} +} + +// JSONFormatterBuilder returns a JSONFormatter appropriate for the given spec version. +// The version gate controls HTML-encoding behaviour introduced in spec 2.12.0 and is +// only meaningful for content that is part of the package spec (sample events, test +// result definitions). For non-spec content, use NewJSONFormatter instead. func JSONFormatterBuilder(specVersion semver.Version) JSONFormatter { if specVersion.LessThan(semver.MustParse("2.12.0")) { return &jsonFormatterWithHTMLEncoding{} diff --git a/internal/requiresupdates/compatibility.go b/internal/requiresupdates/compatibility.go new file mode 100644 index 0000000000..4d936dbfac --- /dev/null +++ b/internal/requiresupdates/compatibility.go @@ -0,0 +1,71 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package requiresupdates + +import ( + "fmt" + "regexp" + "strings" + + "github.com/Masterminds/semver/v3" +) + +var constraintVersionRE = regexp.MustCompile(`(\d+\.\d+\.\d+)`) + +func minVersionFromConstraintBranch(branch string) (string, bool) { + m := constraintVersionRE.FindStringSubmatch(strings.TrimSpace(branch)) + if len(m) < 2 { + return "", false + } + return m[1], true +} + +// kibanaConstraintIsSubset reports whether every Kibana version satisfying +// integrationKibana also satisfies dependencyKibana. +func kibanaConstraintIsSubset(integrationKibana, dependencyKibana string) (bool, error) { + if dependencyKibana == "" { + return true, nil + } + if integrationKibana == "" { + return false, nil + } + depConstraint, err := semver.NewConstraint(dependencyKibana) + if err != nil { + return false, fmt.Errorf("invalid dependency kibana constraint %q: %w", dependencyKibana, err) + } + for _, branch := range strings.Split(integrationKibana, "||") { + branch = strings.TrimSpace(branch) + branchConstraint, err := semver.NewConstraint(branch) + if err != nil { + return false, fmt.Errorf("invalid integration kibana constraint branch %q: %w", branch, err) + } + raw, ok := minVersionFromConstraintBranch(branch) + if !ok { + continue + } + floor, err := semver.NewVersion(raw) + if err != nil { + return false, fmt.Errorf("invalid version in kibana constraint: %w", err) + } + patchPlusOne, _ := semver.NewVersion(fmt.Sprintf("%d.%d.%d", floor.Major(), floor.Minor(), floor.Patch()+1)) + ceiling, _ := semver.NewVersion(fmt.Sprintf("%d.99999.0", floor.Major())) + for _, probe := range []*semver.Version{floor, patchPlusOne, ceiling} { + if probe == nil || !branchConstraint.Check(probe) { + continue + } + if !depConstraint.Check(probe) { + return false, nil + } + } + } + return true, nil +} + +func formatKibanaBumpWarning(depName, depVersion, depKibana, integrationKibana string) string { + return fmt.Sprintf( + "package %s %s is available but requires kibana %s; integration conditions.kibana.version is %s — consider bumping conditions.kibana.version", + depName, depVersion, depKibana, integrationKibana, + ) +} diff --git a/internal/requiresupdates/compatibility_test.go b/internal/requiresupdates/compatibility_test.go new file mode 100644 index 0000000000..a7db74a95f --- /dev/null +++ b/internal/requiresupdates/compatibility_test.go @@ -0,0 +1,96 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package requiresupdates + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestKibanaConstraintIsSubset(t *testing.T) { + tests := []struct { + name string + integration string + dependency string + want bool + }{ + { + name: "identical constraints", + integration: "^9.4.0", + dependency: "^9.4.0", + want: true, + }, + { + name: "non-overlapping ranges", + integration: ">=9.4.0,<9.6.0", + dependency: "^9.6.0", + want: false, + }, + { + name: "dependency range contained in integration range", + integration: ">=9.4.0,<9.6.0", + dependency: "^9.4.0", + want: true, + }, + { + name: "empty integration constraint is not a subset", + integration: "", + dependency: "^9.6.0", + want: false, + }, + { + name: "empty dependency constraint always passes", + integration: "^9.4.0", + dependency: "", + want: true, + }, + { + // Strict-greater lower bound: the regex floor 9.5.0 fails >9.5.0; 9.5.1 must be + // tried as a representative so the window (9.5.0, 9.6.0) is not missed. + name: "strict-greater lower bound covered by patch+1 representative", + integration: ">9.5.0,<9.6.0", + dependency: ">=9.5.1", + want: true, + }, + { + name: "strict-greater range does not satisfy higher constraint", + integration: ">9.5.0,<9.6.0", + dependency: "^9.6.0", + want: false, + }, + { + name: "integration floor below dependency floor is not a subset", + integration: "^9.4.0", + dependency: "^9.6.0", + want: false, + }, + { + name: "OR branch with lower floor prevents subset", + integration: "^9.5.0 || ^10.0.0", + dependency: "^9.6.0", + want: false, + }, + { + name: "OR ordering invariant: ^8.0.0 || ^9.0.0", + integration: "^8.0.0 || ^9.0.0", + dependency: "^9.6.0", + want: false, + }, + { + name: "OR ordering invariant: ^9.0.0 || ^8.0.0", + integration: "^9.0.0 || ^8.0.0", + dependency: "^9.6.0", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ok, err := kibanaConstraintIsSubset(tt.integration, tt.dependency) + require.NoError(t, err) + require.Equal(t, tt.want, ok) + }) + } +} diff --git a/internal/requiresupdates/manifest.go b/internal/requiresupdates/manifest.go new file mode 100644 index 0000000000..87d994369d --- /dev/null +++ b/internal/requiresupdates/manifest.go @@ -0,0 +1,55 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package requiresupdates + +import ( + "bytes" + "fmt" + + "github.com/goccy/go-yaml" + + "github.com/elastic/elastic-package/internal/yamledit" +) + +// setRequiresDependencyVersion updates the version of a package listed under requires.input or requires.content. +func setRequiresDependencyVersion(manifestBytes []byte, section, packageName, newVersion string) ([]byte, error) { + doc, err := yamledit.NewDocumentBytes(manifestBytes) + if err != nil { + return nil, fmt.Errorf("failed to decode manifest: %w", err) + } + + seqPath := fmt.Sprintf("$.requires.%s", section) + seqNode, err := doc.GetSequenceNode(seqPath) + if err != nil { + return nil, fmt.Errorf("manifest has no requires.%s block: %w", section, err) + } + + idx := -1 + for i, v := range seqNode.Values { + var item struct { + Package string `yaml:"package"` + } + if err := yaml.NodeToValue(v, &item); err != nil { + continue + } + if item.Package == packageName { + idx = i + break + } + } + if idx < 0 { + return nil, fmt.Errorf("package %q not found under requires.%s", packageName, section) + } + + if _, err = doc.SetKeyValue(fmt.Sprintf("%s[%d]", seqPath, idx), "version", newVersion, 0); err != nil { + return nil, fmt.Errorf("updating version for package %q: %w", packageName, err) + } + + var buf bytes.Buffer + if _, err = doc.Write(&buf); err != nil { + return nil, fmt.Errorf("writing manifest: %w", err) + } + return buf.Bytes(), nil +} diff --git a/internal/requiresupdates/manifest_test.go b/internal/requiresupdates/manifest_test.go new file mode 100644 index 0000000000..349f11c3dd --- /dev/null +++ b/internal/requiresupdates/manifest_test.go @@ -0,0 +1,79 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package requiresupdates + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +const sampleManifest = `name: test_integration +version: 1.0.0 +type: integration +requires: + input: + - package: sql_input + version: "0.2.0" + content: + - package: dashboards + version: "^0.1.0" +` + +func TestSetRequiresDependencyVersion(t *testing.T) { + updated, err := setRequiresDependencyVersion([]byte(sampleManifest), "input", "sql_input", "0.3.0") + require.NoError(t, err) + require.Contains(t, string(updated), "0.3.0") + require.Contains(t, string(updated), "dashboards") + require.Contains(t, string(updated), "^0.1.0") +} + +func TestSetRequiresDependencyVersion_roundTripFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yml") + require.NoError(t, os.WriteFile(path, []byte(sampleManifest), 0o644)) + + data, err := os.ReadFile(path) + require.NoError(t, err) + data, err = setRequiresDependencyVersion(data, "content", "dashboards", "0.2.0") + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o644)) + + rewritten, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(rewritten), `0.2.0`) +} + +func TestSetRequiresDependencyVersion_versionUpdated(t *testing.T) { + t.Run("unquoted constraint becomes unquoted exact", func(t *testing.T) { + manifest := `name: test_integration +version: 1.0.0 +type: integration +requires: + content: + - package: dashboards + version: ^0.3.0 +` + updated, err := setRequiresDependencyVersion([]byte(manifest), "content", "dashboards", "0.4.0") + require.NoError(t, err) + require.Contains(t, string(updated), `0.4.0`) + }) + + t.Run("quoted constraint updated to new version", func(t *testing.T) { + manifest := `name: test_integration +version: 1.0.0 +type: integration +requires: + content: + - package: dashboards + version: "^0.3.0" +` + updated, err := setRequiresDependencyVersion([]byte(manifest), "content", "dashboards", "0.4.0") + require.NoError(t, err) + require.Contains(t, string(updated), `0.4.0`) + }) +} diff --git a/internal/requiresupdates/updates.go b/internal/requiresupdates/updates.go new file mode 100644 index 0000000000..dd42231242 --- /dev/null +++ b/internal/requiresupdates/updates.go @@ -0,0 +1,351 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package requiresupdates + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + + "github.com/elastic/elastic-package/internal/logger" + "github.com/elastic/elastic-package/internal/packages" + "github.com/elastic/elastic-package/internal/registry" +) + +const integrationPackageType = "integration" + +// DependencyKind identifies a requires block entry. +type DependencyKind string + +const ( + InputDependency DependencyKind = "input" + ContentDependency DependencyKind = "content" +) + +// UpdateProposal describes a single dependency version bump. +type UpdateProposal struct { + Kind DependencyKind `json:"kind"` + Package string `json:"package"` + Current string `json:"current"` + Proposed string `json:"proposed"` + Warning string `json:"warning,omitempty"` + KibanaConstraint string `json:"kibana_constraint,omitempty"` +} + +// Options configures a requires update run. +type Options struct { + PackageRoot string + RegistryClient *registry.Client + Prerelease bool +} + +// Result holds proposals from a Resolve call. +type Result struct { + Package string `json:"package"` + CodeOwner string `json:"codeowner,omitempty"` + Proposals []UpdateProposal `json:"proposals,omitempty"` + SkipReason string `json:"skip_reason,omitempty"` // set when the package is not applicable (not an error) +} + +func resultFromManifest(manifest *packages.PackageManifest) Result { + return Result{ + Package: manifest.Name, + CodeOwner: manifest.Owner.Github, + } +} + +// Resolve reads the package manifest and resolves dependency version bumps +// without writing any files. Call Apply to produce updated manifest bytes, and +// write the result to disk only when not doing a dry-run. +func Resolve(opts Options) (*Result, error) { + manifest, err := packages.ReadPackageManifestFromPackageRoot(opts.PackageRoot) + if err != nil { + return nil, fmt.Errorf("reading package manifest failed: %w", err) + } + result := resultFromManifest(manifest) + if manifest.Type != integrationPackageType { + result.SkipReason = fmt.Sprintf( + "package type is %q; requires update only applies to integration packages with requires", + manifest.Type, + ) + return &result, nil + } + if manifest.Requires == nil || (len(manifest.Requires.Input) == 0 && len(manifest.Requires.Content) == 0) { + result.SkipReason = "package has no requires.input or requires.content dependencies; requires update only applies to integration packages with requires" + return &result, nil + } + + integrationKibana := manifest.Conditions.Kibana.Version + logger.Debugf("resolving requires for %q (type=%s, kibana=%q, input=%d, content=%d)", + manifest.Name, manifest.Type, integrationKibana, + len(manifest.Requires.Input), len(manifest.Requires.Content)) + proposals := make([]UpdateProposal, 0, len(manifest.Requires.Input)+len(manifest.Requires.Content)) + + inputProposals, err := resolveSection(opts, integrationKibana, InputDependency, manifest.Requires.Input) + if err != nil { + return nil, err + } + proposals = append(proposals, inputProposals...) + + contentProposals, err := resolveSection(opts, integrationKibana, ContentDependency, manifest.Requires.Content) + if err != nil { + return nil, err + } + proposals = append(proposals, contentProposals...) + + result.Proposals = proposals + return &result, nil +} + +// Apply applies the proposed version bumps in proposals to manifestBytes and +// returns the modified YAML. Proposals with an empty Proposed field are skipped. +func Apply(manifestBytes []byte, proposals []UpdateProposal) ([]byte, error) { + for _, p := range proposals { + if p.Proposed == "" { + continue + } + logger.Debugf("applying %s.%s: %s -> %s", p.Kind, p.Package, p.Current, p.Proposed) + var err error + manifestBytes, err = setRequiresDependencyVersion(manifestBytes, string(p.Kind), p.Package, p.Proposed) + if err != nil { + return nil, fmt.Errorf("updating requires.%s for package %q: %w", p.Kind, p.Package, err) + } + } + return manifestBytes, nil +} + +func resolveSection(opts Options, integrationKibana string, kind DependencyKind, deps []packages.PackageDependency) ([]UpdateProposal, error) { + if len(deps) == 0 { + return nil, nil + } + var proposals []UpdateProposal + for _, dep := range deps { + proposal, err := resolveDependency(opts, integrationKibana, kind, dep) + if err != nil { + return nil, err + } + if proposal != nil { + proposals = append(proposals, *proposal) + } + } + return proposals, nil +} + +func resolveDependency(opts Options, integrationKibana string, kind DependencyKind, dep packages.PackageDependency) (*UpdateProposal, error) { + logger.Debugf("resolving dependency %q (kind=%s, current=%s)", dep.Package, kind, dep.Version) + unfiltered, err := fetchAllRevisions(opts.RegistryClient, dep.Package, opts.Prerelease) + if err != nil { + return nil, fmt.Errorf("retrieving revisions for package %q failed: %w", dep.Package, err) + } + if len(unfiltered) == 0 { + return nil, nil + } + + compatible, err := filterByKibanaSubset(unfiltered, integrationKibana) + if err != nil { + return nil, err + } + logger.Debugf("%d/%d revisions compatible for %q", len(compatible), len(unfiltered), dep.Package) + + currentEffective, currentConstraint, err := parseCurrentVersion(kind, dep.Version) + if err != nil { + return nil, fmt.Errorf("package %q: %w", dep.Package, err) + } + + isOutdatedBy := func(ver *semver.Version) bool { + return isVersionOutdated(currentEffective, currentConstraint, ver) + } + + var latestCompatible *packages.PackageManifest + if currentConstraint != nil { + latestCompatible = latestRevisionBeyondConstraint(compatible, currentConstraint) + } else { + latestCompatible = latestRevisionNewerThan(compatible, currentEffective) + } + + latestUnfiltered := latestRevision(unfiltered) + + if latestCompatible == nil { + logger.Debugf("no compatible version found for %q, checking for kibana bump warning", dep.Package) + warning := kibanaBumpWarning(dep, latestUnfiltered, integrationKibana, nil, isOutdatedBy) + if warning == "" { + return nil, nil + } + return &UpdateProposal{ + Kind: kind, + Package: dep.Package, + Current: dep.Version, + Warning: warning, + }, nil + } + + latestCompatibleVer, err := semver.NewVersion(latestCompatible.Version) + if err != nil { + return nil, fmt.Errorf("invalid compatible version %q: %w", latestCompatible.Version, err) + } + if !isOutdatedBy(latestCompatibleVer) { + warning := kibanaBumpWarning(dep, latestUnfiltered, integrationKibana, latestCompatibleVer, isOutdatedBy) + if warning == "" { + return nil, nil + } + return &UpdateProposal{ + Kind: kind, + Package: dep.Package, + Current: dep.Version, + Warning: warning, + }, nil + } + + proposal := &UpdateProposal{ + Kind: kind, + Package: dep.Package, + Current: dep.Version, + Proposed: latestCompatible.Version, + KibanaConstraint: latestCompatible.Conditions.Kibana.Version, + Warning: kibanaBumpWarning(dep, latestUnfiltered, integrationKibana, latestCompatibleVer, nil), + } + logger.Debugf("proposal for %q: %s -> %s", dep.Package, dep.Version, latestCompatible.Version) + return proposal, nil +} + +// fetchAllRevisions fetches all versions of packageName from the registry. +func fetchAllRevisions(client *registry.Client, packageName string, prerelease bool) ([]packages.PackageManifest, error) { + revisions, err := client.Revisions(packageName, registry.SearchOptions{ + All: true, + Prerelease: prerelease, + Experimental: true, + }) + if err != nil { + return nil, err + } + logger.Debugf("fetched %d revisions for %q (prerelease=%v)", len(revisions), packageName, prerelease) + // If no stable versions exist, fall back to pre-releases so that packages + // that have not yet shipped a stable release are still visible. Without this, + // resolveDependency would treat the dependency as non-existent and produce + // neither a proposal nor a warning. + if len(revisions) == 0 && !prerelease { + logger.Debugf("no stable revisions for %q, retrying with prerelease", packageName) + revisions, err = client.Revisions(packageName, registry.SearchOptions{ + All: true, + Prerelease: true, + Experimental: true, + }) + if err != nil { + return nil, err + } + } + return revisions, nil +} + +func filterByKibanaSubset(revisions []packages.PackageManifest, integrationKibana string) ([]packages.PackageManifest, error) { + var filtered []packages.PackageManifest + for _, rev := range revisions { + logger.Debugf("checking %s %s: integration kibana=%q dep kibana=%q", + rev.Name, rev.Version, integrationKibana, rev.Conditions.Kibana.Version) + ok, err := kibanaConstraintIsSubset(integrationKibana, rev.Conditions.Kibana.Version) + if err != nil { + return nil, err + } + if ok { + filtered = append(filtered, rev) + } + } + return filtered, nil +} + +// parseCurrentVersion returns the current dependency version as either an exact +// semver.Version or a semver.Constraints, depending on the dep kind and the +// string format. Input deps must be exact semver pins; content deps additionally +// accept constraint expressions (e.g. "^0.3.0"). +func parseCurrentVersion(kind DependencyKind, version string) (*semver.Version, *semver.Constraints, error) { + if kind != ContentDependency { + v, err := semver.NewVersion(version) + if err != nil { + return nil, nil, fmt.Errorf("invalid requires version %q (must be an exact semver, not a constraint): %w", version, err) + } + return v, nil, nil + } + if v, err := semver.NewVersion(version); err == nil { + return v, nil, nil + } + c, err := semver.NewConstraint(version) + if err != nil { + return nil, nil, fmt.Errorf("invalid requires version %q (not a valid semver or constraint): %w", version, err) + } + return nil, c, nil +} + +// isVersionOutdated reports whether ver represents a version bump over the current spec. +// For an exact pin: ver must be strictly greater. For a constraint: ver must fall +// outside it (i.e. it's a newer range the constraint does not cover). +func isVersionOutdated(current *semver.Version, constraint *semver.Constraints, ver *semver.Version) bool { + if constraint != nil { + return !constraint.Check(ver) + } + return current != nil && ver.GreaterThan(current) +} + +// kibanaBumpWarning returns a warning when latest is available but requires a +// Kibana version incompatible with the integration. minVer, when non-nil, gates +// the warning on latest being strictly greater than that baseline. isOutdated, +// when non-nil, additionally requires the version to represent an actual bump +// over the current dependency spec. +func kibanaBumpWarning(dep packages.PackageDependency, latest *packages.PackageManifest, integrationKibana string, minVer *semver.Version, isOutdated func(*semver.Version) bool) string { + if latest == nil { + return "" + } + v, _ := semver.NewVersion(latest.Version) + if v == nil { + return "" + } + if minVer != nil && !v.GreaterThan(minVer) { + return "" + } + if isOutdated != nil && !isOutdated(v) { + return "" + } + return formatKibanaBumpWarning(dep.Package, latest.Version, latest.Conditions.Kibana.Version, integrationKibana) +} + +// latestRevisionWhere returns the manifest with the highest parseable semantic +// version among those for which keep returns true. +func latestRevisionWhere(revisions []packages.PackageManifest, keep func(*semver.Version) bool) *packages.PackageManifest { + var best *packages.PackageManifest + var bestVer *semver.Version + for i := range revisions { + ver, err := semver.NewVersion(revisions[i].Version) + if err != nil || !keep(ver) { + continue + } + if bestVer == nil || ver.GreaterThan(bestVer) { + revCopy := revisions[i] + best = &revCopy + bestVer = ver + } + } + return best +} + +// latestRevision returns the revision with the highest semantic version. +// Entries with unparseable versions are skipped. Returns nil when the slice is +// empty or every entry has an unparseable version. +func latestRevision(revisions []packages.PackageManifest) *packages.PackageManifest { + return latestRevisionWhere(revisions, func(*semver.Version) bool { return true }) +} + +// latestRevisionBeyondConstraint returns the latest revision whose version does +// not satisfy constraint — meaning it falls outside the currently-pinned range +// and would represent a version bump. +func latestRevisionBeyondConstraint(revisions []packages.PackageManifest, constraint *semver.Constraints) *packages.PackageManifest { + return latestRevisionWhere(revisions, func(ver *semver.Version) bool { + return !constraint.Check(ver) + }) +} + +func latestRevisionNewerThan(revisions []packages.PackageManifest, current *semver.Version) *packages.PackageManifest { + return latestRevisionWhere(revisions, func(ver *semver.Version) bool { + return current == nil || ver.GreaterThan(current) + }) +} diff --git a/internal/requiresupdates/updates_test.go b/internal/requiresupdates/updates_test.go new file mode 100644 index 0000000000..e545072cd5 --- /dev/null +++ b/internal/requiresupdates/updates_test.go @@ -0,0 +1,603 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package requiresupdates + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/require" + + "github.com/elastic/elastic-package/internal/packages" + "github.com/elastic/elastic-package/internal/registry" +) + +func TestResolve(t *testing.T) { + tests := []struct { + name string + revisions []packages.PackageManifest // nil = no registry server + manifest string + prerelease bool + check func(t *testing.T, result *Result, err error, manifest string) + }{ + { + name: "bumps compatible dependency", + revisions: []packages.PackageManifest{ + manifestRevision("0.2.0", "^9.4.0"), + manifestRevision("0.3.0", "^9.4.0"), + }, + manifest: `name: test_pkg +version: 1.0.0 +type: integration +owner: + github: elastic/integrations +conditions: + kibana: + version: "^9.4.0" +requires: + input: + - package: sql_input + version: "0.2.0" +`, + check: func(t *testing.T, result *Result, err error, _ string) { + require.NoError(t, err) + require.Equal(t, "test_pkg", result.Package) + require.Equal(t, "elastic/integrations", result.CodeOwner) + require.Len(t, result.Proposals, 1) + require.Equal(t, "0.2.0", result.Proposals[0].Current) + require.Equal(t, "0.3.0", result.Proposals[0].Proposed) + require.Empty(t, result.Proposals[0].Warning) + }, + }, + { + name: "warns when newer dependency requires higher kibana", + revisions: []packages.PackageManifest{ + manifestRevision("0.2.0", "^9.4.0"), + manifestRevision("0.3.0", "^9.4.0"), + manifestRevision("0.5.0", "^9.6.0"), + }, + manifest: `name: test_pkg +version: 1.0.0 +type: integration +conditions: + kibana: + version: ">=9.4.0,<9.6.0" +requires: + input: + - package: sql_input + version: "0.2.0" +`, + check: func(t *testing.T, result *Result, err error, _ string) { + require.NoError(t, err) + require.Len(t, result.Proposals, 1) + require.Equal(t, "0.3.0", result.Proposals[0].Proposed) + require.Contains(t, result.Proposals[0].Warning, "0.5.0") + require.Contains(t, result.Proposals[0].Warning, "^9.6.0") + }, + }, + { + name: "apply round-trip updates manifest version", + revisions: []packages.PackageManifest{ + manifestRevision("0.2.0", "^9.4.0"), + manifestRevision("0.4.0", "^9.4.0"), + }, + manifest: `name: test_pkg +version: 1.0.0 +type: integration +conditions: + kibana: + version: "^9.4.0" +requires: + input: + - package: sql_input + version: "0.2.0" +`, + check: func(t *testing.T, result *Result, err error, manifest string) { + require.NoError(t, err) + require.Len(t, result.Proposals, 1) + require.Equal(t, "0.4.0", result.Proposals[0].Proposed) + + updated, err := Apply([]byte(manifest), result.Proposals) + require.NoError(t, err) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "manifest.yml"), updated, 0o644)) + pkg, err := packages.ReadPackageManifestFromPackageRoot(dir) + require.NoError(t, err) + require.Equal(t, "0.4.0", pkg.Requires.Input[0].Version) + }, + }, + { + name: "skips non-integration package type", + manifest: `name: test_input +version: 1.0.0 +type: input +`, + check: func(t *testing.T, result *Result, err error, _ string) { + require.NoError(t, err) + require.NotEmpty(t, result.SkipReason) + require.Contains(t, result.SkipReason, "integration") + }, + }, + { + name: "skips integration without requires section", + manifest: `name: test_integration +version: 1.0.0 +type: integration +`, + check: func(t *testing.T, result *Result, err error, _ string) { + require.NoError(t, err) + require.NotEmpty(t, result.SkipReason) + require.Contains(t, result.SkipReason, "requires") + }, + }, + { + name: "warning only when all revisions require higher kibana", + revisions: []packages.PackageManifest{ + manifestRevision("0.3.0", "^9.6.0"), + }, + manifest: `name: test_pkg +version: 1.0.0 +type: integration +conditions: + kibana: + version: ">=9.4.0,<9.6.0" +requires: + input: + - package: sql_input + version: "0.2.0" +`, + check: func(t *testing.T, result *Result, err error, _ string) { + require.NoError(t, err) + require.Len(t, result.Proposals, 1) + p := result.Proposals[0] + require.Equal(t, "0.2.0", p.Current) + require.Empty(t, p.Proposed, "expected no proposed version when no compatible revision exists") + require.NotEmpty(t, p.Warning, "expected a warning when only a higher-Kibana revision is available") + require.Contains(t, p.Warning, "0.3.0") + require.Contains(t, p.Warning, "^9.6.0") + }, + }, + { + name: "content dep exact pin bumped", + revisions: []packages.PackageManifest{ + manifestRevision("0.2.0", "^9.4.0"), + manifestRevision("0.3.0", "^9.4.0"), + }, + manifest: `name: test_pkg +version: 1.0.0 +type: integration +conditions: + kibana: + version: "^9.4.0" +requires: + content: + - package: sql_input + version: "0.2.0" +`, + check: func(t *testing.T, result *Result, err error, _ string) { + require.NoError(t, err) + require.Len(t, result.Proposals, 1) + p := result.Proposals[0] + require.Equal(t, ContentDependency, p.Kind) + require.Equal(t, "0.2.0", p.Current) + require.Equal(t, "0.3.0", p.Proposed) + require.Empty(t, p.Warning) + }, + }, + { + name: "content dep constraint style bumps beyond current range", + revisions: []packages.PackageManifest{ + manifestRevision("0.3.5", "^9.4.0"), + manifestRevision("0.4.0", "^9.4.0"), + }, + manifest: `name: test_pkg +version: 1.0.0 +type: integration +conditions: + kibana: + version: "^9.4.0" +requires: + content: + - package: sql_input + version: "^0.3.0" +`, + check: func(t *testing.T, result *Result, err error, _ string) { + require.NoError(t, err) + require.Len(t, result.Proposals, 1) + p := result.Proposals[0] + require.Equal(t, ContentDependency, p.Kind) + require.Equal(t, "^0.3.0", p.Current) + require.Equal(t, "0.4.0", p.Proposed) + require.Empty(t, p.Warning) + }, + }, + { + name: "content dep constraint style no update when all versions satisfy", + revisions: []packages.PackageManifest{ + manifestRevision("0.3.5", "^9.4.0"), + }, + manifest: `name: test_pkg +version: 1.0.0 +type: integration +conditions: + kibana: + version: "^9.4.0" +requires: + content: + - package: sql_input + version: "^0.3.0" +`, + check: func(t *testing.T, result *Result, err error, _ string) { + require.NoError(t, err) + require.Empty(t, result.Proposals) + }, + }, + { + name: "errors on constraint style input pin", + revisions: []packages.PackageManifest{ + manifestRevision("0.2.0", "^9.4.0"), + manifestRevision("0.3.0", "^9.4.0"), + }, + manifest: `name: test_pkg +version: 1.0.0 +type: integration +conditions: + kibana: + version: "^9.4.0" +requires: + input: + - package: sql_input + version: "^0.2.0" +`, + check: func(t *testing.T, _ *Result, err error, _ string) { + require.Error(t, err) + require.Contains(t, err.Error(), "not a constraint") + }, + }, + { + name: "prerelease only fallback when no stable versions exist", + revisions: []packages.PackageManifest{ + manifestRevision("0.1.0-beta.1", "^9.4.0"), + manifestRevision("0.2.0-beta.1", "^9.4.0"), + }, + manifest: `name: test_pkg +version: 1.0.0 +type: integration +conditions: + kibana: + version: "^9.4.0" +requires: + input: + - package: sql_input + version: "0.1.0-beta.1" +`, + prerelease: false, + check: func(t *testing.T, result *Result, err error, _ string) { + require.NoError(t, err) + require.Len(t, result.Proposals, 1) + require.Equal(t, "0.1.0-beta.1", result.Proposals[0].Current) + require.Equal(t, "0.2.0-beta.1", result.Proposals[0].Proposed) + }, + }, + { + name: "prerelease excluded when stable versions exist", + revisions: []packages.PackageManifest{ + manifestRevision("0.2.0", "^9.4.0"), + manifestRevision("0.3.0-beta.1", "^9.4.0"), + }, + manifest: `name: test_pkg +version: 1.0.0 +type: integration +conditions: + kibana: + version: "^9.4.0" +requires: + input: + - package: sql_input + version: "0.1.0" +`, + prerelease: false, + check: func(t *testing.T, result *Result, err error, _ string) { + require.NoError(t, err) + require.Len(t, result.Proposals, 1) + require.Equal(t, "0.2.0", result.Proposals[0].Proposed) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + opts := Options{ + PackageRoot: writeIntegrationPackage(t, tc.manifest), + Prerelease: tc.prerelease, + } + if tc.revisions != nil { + srv, client := testRegistryServer(t, tc.revisions) + t.Cleanup(srv.Close) + opts.RegistryClient = client + } + result, err := Resolve(opts) + tc.check(t, result, err, tc.manifest) + }) + } +} + +func TestApply(t *testing.T) { + tests := []struct { + name string + manifest string + proposals []UpdateProposal + check func(t *testing.T, updated []byte, err error) + }{ + { + name: "multi-proposal updates both input and content", + manifest: sampleManifest, + proposals: []UpdateProposal{ + {Kind: InputDependency, Package: "sql_input", Current: "0.2.0", Proposed: "0.3.0"}, + {Kind: ContentDependency, Package: "dashboards", Current: "^0.1.0", Proposed: "0.2.0"}, + }, + check: func(t *testing.T, updated []byte, err error) { + require.NoError(t, err) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "manifest.yml"), updated, 0o644)) + pkg, err := packages.ReadPackageManifestFromPackageRoot(dir) + require.NoError(t, err) + require.Equal(t, "0.3.0", pkg.Requires.Input[0].Version) + require.Equal(t, "0.2.0", pkg.Requires.Content[0].Version) + }, + }, + { + name: "skips proposal with empty proposed version", + manifest: sampleManifest, + proposals: []UpdateProposal{ + {Kind: InputDependency, Package: "sql_input", Current: "0.2.0", Proposed: "", Warning: "needs kibana bump"}, + }, + check: func(t *testing.T, updated []byte, err error) { + require.NoError(t, err) + require.Equal(t, []byte(sampleManifest), updated) + }, + }, + { + name: "unknown package returns error", + manifest: sampleManifest, + proposals: []UpdateProposal{ + {Kind: InputDependency, Package: "nonexistent", Current: "0.1.0", Proposed: "0.2.0"}, + }, + check: func(t *testing.T, _ []byte, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "nonexistent") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + updated, err := Apply([]byte(tc.manifest), tc.proposals) + tc.check(t, updated, err) + }) + } +} + +func TestLatestRevision(t *testing.T) { + t.Run("empty", func(t *testing.T) { + require.Nil(t, latestRevision(nil)) + }) + + t.Run("single", func(t *testing.T) { + revisions := []packages.PackageManifest{manifestRevision("1.0.0", "^9.0.0")} + require.Equal(t, "1.0.0", latestRevision(revisions).Version) + }) + + t.Run("unsorted picks max", func(t *testing.T) { + // Input order is intentionally non-ascending to confirm no sorted assumption. + revisions := []packages.PackageManifest{ + manifestRevision("0.3.0", "^9.0.0"), + manifestRevision("0.1.0", "^9.0.0"), + manifestRevision("0.5.0", "^9.0.0"), + manifestRevision("0.2.0", "^9.0.0"), + } + require.Equal(t, "0.5.0", latestRevision(revisions).Version) + }) + + t.Run("skips unparseable versions", func(t *testing.T) { + revisions := []packages.PackageManifest{ + manifestRevision("not-a-version", "^9.0.0"), + manifestRevision("0.2.0", "^9.0.0"), + manifestRevision("bad", "^9.0.0"), + } + require.Equal(t, "0.2.0", latestRevision(revisions).Version) + }) + + t.Run("all unparseable returns nil", func(t *testing.T) { + revisions := []packages.PackageManifest{ + manifestRevision("not-semver", "^9.0.0"), + } + require.Nil(t, latestRevision(revisions)) + }) +} + +func manifestRevision(version, kibana string) packages.PackageManifest { + return packages.PackageManifest{ + Name: "sql_input", + Version: version, + Type: "input", + Conditions: packages.Conditions{ + Kibana: packages.KibanaConditions{Version: kibana}, + }, + } +} + +func testRegistryServer(t *testing.T, revisions []packages.PackageManifest) (*httptest.Server, *registry.Client) { + t.Helper() + byPackage := make(map[string][]packages.PackageManifest) + for _, rev := range revisions { + byPackage[rev.Name] = append(byPackage[rev.Name], rev) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/search" { + http.NotFound(w, r) + return + } + pkg := r.URL.Query().Get("package") + all := byPackage[pkg] + includePrerelease := r.URL.Query().Get("prerelease") == "true" + if !includePrerelease { + var stable []packages.PackageManifest + for _, rev := range all { + v, err := semver.NewVersion(rev.Version) + if err != nil || v.Prerelease() == "" { + stable = append(stable, rev) + } + } + all = stable + } + body, err := json.Marshal(all) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + client, err := registry.NewClient(srv.URL) + require.NoError(t, err) + return srv, client +} + +func writeIntegrationPackage(t *testing.T, manifest string) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "manifest.yml"), []byte(manifest), 0o644)) + return dir +} + +// requestCapturingServer creates an httptest.Server that records all received +// requests (protected by a mutex) and delegates to handler. Read *reqs only +// after the fetchAllRevisions call returns. +func requestCapturingServer(t *testing.T, handler http.HandlerFunc) (*registry.Client, *[]http.Request) { + t.Helper() + var mu sync.Mutex + var reqs []http.Request + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + reqs = append(reqs, *r) + mu.Unlock() + handler(w, r) + })) + t.Cleanup(srv.Close) + client, err := registry.NewClient(srv.URL) + require.NoError(t, err) + return client, &reqs +} + +func TestFetchAllRevisions(t *testing.T) { + jsonBody := func(revisions []packages.PackageManifest) []byte { + b, _ := json.Marshal(revisions) + return b + } + + tests := []struct { + name string + prerelease bool + handler http.HandlerFunc + check func(t *testing.T, revisions []packages.PackageManifest, err error, reqs []http.Request) + }{ + { + name: "correct query params sent", + prerelease: false, + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("[]")) + }, + check: func(t *testing.T, _ []packages.PackageManifest, _ error, reqs []http.Request) { + require.GreaterOrEqual(t, len(reqs), 1) + q := reqs[0].URL.Query() + require.Equal(t, "true", q.Get("all")) + require.Equal(t, "true", q.Get("experimental")) + require.Equal(t, "sql_input", q.Get("package")) + require.Equal(t, "false", q.Get("prerelease")) + }, + }, + { + name: "prerelease param forwarded", + prerelease: true, + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jsonBody([]packages.PackageManifest{manifestRevision("0.1.0-beta.1", "^9.4.0")})) + }, + check: func(t *testing.T, _ []packages.PackageManifest, _ error, reqs []http.Request) { + require.GreaterOrEqual(t, len(reqs), 1) + require.Equal(t, "true", reqs[0].URL.Query().Get("prerelease")) + }, + }, + { + name: "fallback to prerelease when stable list is empty", + prerelease: false, + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("prerelease") == "true" { + _, _ = w.Write(jsonBody([]packages.PackageManifest{manifestRevision("0.1.0-beta.1", "^9.4.0")})) + return + } + _, _ = w.Write([]byte("[]")) + }, + check: func(t *testing.T, revisions []packages.PackageManifest, err error, reqs []http.Request) { + require.NoError(t, err) + require.Len(t, reqs, 2) + require.Equal(t, "false", reqs[0].URL.Query().Get("prerelease")) + require.Equal(t, "true", reqs[1].URL.Query().Get("prerelease")) + require.Len(t, revisions, 1) + require.Equal(t, "0.1.0-beta.1", revisions[0].Version) + }, + }, + { + name: "no fallback when stable versions exist", + prerelease: false, + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jsonBody([]packages.PackageManifest{manifestRevision("0.2.0", "^9.4.0")})) + }, + check: func(t *testing.T, revisions []packages.PackageManifest, err error, reqs []http.Request) { + require.NoError(t, err) + require.Len(t, reqs, 1) + require.Len(t, revisions, 1) + }, + }, + { + name: "server error propagated", + prerelease: false, + handler: func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal error", http.StatusInternalServerError) + }, + check: func(t *testing.T, _ []packages.PackageManifest, err error, _ []http.Request) { + require.Error(t, err) + }, + }, + { + name: "both calls fire when all responses empty", + prerelease: false, + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("[]")) + }, + check: func(t *testing.T, revisions []packages.PackageManifest, err error, reqs []http.Request) { + require.NoError(t, err) + require.Empty(t, revisions) + require.Len(t, reqs, 2, "stable call returns empty so prerelease fallback should fire") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client, reqs := requestCapturingServer(t, tc.handler) + revisions, err := fetchAllRevisions(client, "sql_input", tc.prerelease) + tc.check(t, revisions, err, *reqs) + }) + } +}