diff --git a/build/testdata/bundles/mysql-interface/porter.yaml b/build/testdata/bundles/mysql-interface/porter.yaml new file mode 100644 index 000000000..387eb582b --- /dev/null +++ b/build/testdata/bundles/mysql-interface/porter.yaml @@ -0,0 +1,29 @@ +schemaVersion: 1.0.1 +name: mysql-interface +version: 0.1.0 +registry: "localhost:5000" + +# This bundle is never installed -- it exists only to be published and +# referenced via interface.reference by a dependency elsewhere (see #2626), +# declaring the outputs a bundle must provide to satisfy the mysql +# interface. + +mixins: + - exec + +install: + - exec: + command: echo + arguments: + - "installed mysql-interface" + +uninstall: + - exec: + command: echo + arguments: + - "uninstalled mysql-interface" + +outputs: + - name: mysql-password + description: "The mysql database password" + type: string diff --git a/build/testdata/bundles/wordpressv2-interface-missing/helpers.sh b/build/testdata/bundles/wordpressv2-interface-missing/helpers.sh new file mode 100755 index 000000000..c8d1b00a7 --- /dev/null +++ b/build/testdata/bundles/wordpressv2-interface-missing/helpers.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +install() { + mkdir -p /cnab/app/outputs + echo "topsecret-blog" >> /cnab/app/outputs/wordpress-password +} + +ping() { + echo ping +} + +# Call the requested function and pass the arguments as-is +"$@" diff --git a/build/testdata/bundles/wordpressv2-interface-missing/porter.yaml b/build/testdata/bundles/wordpressv2-interface-missing/porter.yaml new file mode 100644 index 000000000..c74ec3049 --- /dev/null +++ b/build/testdata/bundles/wordpressv2-interface-missing/porter.yaml @@ -0,0 +1,83 @@ +schemaVersion: 1.1.0 +name: wordpress-interface-missing +version: 0.1.4 +registry: "localhost:5000" + +mixins: + - exec + - helm3: + repositories: + bitnami: + url: "https://charts.bitnami.com/bitnami" + +dependencies: + requires: + - name: mysql + bundle: + reference: localhost:5000/mysql:v0.1.4 + interface: + document: + outputs: + # mysql never produces this output -- any existing mysql + # installation fails to satisfy this interface, so the + # dependency must always be pulled instead of reused. + - name: mysql-connstr + sharing: + mode: true + group: + name: myapp + parameters: + database-name: wordpress + mysql-user: wordpress + namespace: wordpress + +parameters: +- name: wordpress-name + type: string + default: porter-ci-wordpress + env: WORDPRESS_NAME +- name: wordpress-password + type: string + sensitive: true + applyTo: + - install + - upgrade +- name: namespace + type: string + default: 'wordpress' + +install: + - exec: + command: ./helpers.sh + arguments: + - install + +upgrade: + - exec: + command: ./helpers.sh + arguments: + - install + +ping: + - exec: + description: "Ping" + command: ./helpers.sh + arguments: + - ping + +uninstall: + - exec: + command: echo + arguments: + - uninstall wordpress + +outputs: + - name: wordpress-password + description: "The Wordpress installation password" + type: string + default: "default-password" + applyTo: + - "install" + - "upgrade" + sensitive: true + path: /cnab/app/outputs/wordpress-password diff --git a/build/testdata/bundles/wordpressv2-interface-reference/helpers.sh b/build/testdata/bundles/wordpressv2-interface-reference/helpers.sh new file mode 100755 index 000000000..c8d1b00a7 --- /dev/null +++ b/build/testdata/bundles/wordpressv2-interface-reference/helpers.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +install() { + mkdir -p /cnab/app/outputs + echo "topsecret-blog" >> /cnab/app/outputs/wordpress-password +} + +ping() { + echo ping +} + +# Call the requested function and pass the arguments as-is +"$@" diff --git a/build/testdata/bundles/wordpressv2-interface-reference/porter.yaml b/build/testdata/bundles/wordpressv2-interface-reference/porter.yaml new file mode 100644 index 000000000..e72957c83 --- /dev/null +++ b/build/testdata/bundles/wordpressv2-interface-reference/porter.yaml @@ -0,0 +1,78 @@ +schemaVersion: 1.1.0 +name: wordpress-interface-reference +version: 0.1.4 +registry: "localhost:5000" + +mixins: + - exec + - helm3: + repositories: + bitnami: + url: "https://charts.bitnami.com/bitnami" + +dependencies: + requires: + - name: mysql + bundle: + reference: localhost:5000/mysql:v0.1.4 + interface: + reference: localhost:5000/mysql-interface:v0.1.0 + sharing: + mode: true + group: + name: myapp + parameters: + database-name: wordpress + mysql-user: wordpress + namespace: wordpress + +parameters: +- name: wordpress-name + type: string + default: porter-ci-wordpress + env: WORDPRESS_NAME +- name: wordpress-password + type: string + sensitive: true + applyTo: + - install + - upgrade +- name: namespace + type: string + default: 'wordpress' + +install: + - exec: + command: ./helpers.sh + arguments: + - install + +upgrade: + - exec: + command: ./helpers.sh + arguments: + - install + +ping: + - exec: + description: "Ping" + command: ./helpers.sh + arguments: + - ping + +uninstall: + - exec: + command: echo + arguments: + - uninstall wordpress + +outputs: + - name: wordpress-password + description: "The Wordpress installation password" + type: string + default: "default-password" + applyTo: + - "install" + - "upgrade" + sensitive: true + path: /cnab/app/outputs/wordpress-password diff --git a/build/testdata/bundles/wordpressv2-interface/helpers.sh b/build/testdata/bundles/wordpressv2-interface/helpers.sh new file mode 100755 index 000000000..c8d1b00a7 --- /dev/null +++ b/build/testdata/bundles/wordpressv2-interface/helpers.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +install() { + mkdir -p /cnab/app/outputs + echo "topsecret-blog" >> /cnab/app/outputs/wordpress-password +} + +ping() { + echo ping +} + +# Call the requested function and pass the arguments as-is +"$@" diff --git a/build/testdata/bundles/wordpressv2-interface/porter.yaml b/build/testdata/bundles/wordpressv2-interface/porter.yaml new file mode 100644 index 000000000..e0be3186b --- /dev/null +++ b/build/testdata/bundles/wordpressv2-interface/porter.yaml @@ -0,0 +1,80 @@ +schemaVersion: 1.1.0 +name: wordpress-interface +version: 0.1.4 +registry: "localhost:5000" + +mixins: + - exec + - helm3: + repositories: + bitnami: + url: "https://charts.bitnami.com/bitnami" + +dependencies: + requires: + - name: mysql + bundle: + reference: localhost:5000/mysql:v0.1.4 + interface: + document: + outputs: + - name: mysql-password + sharing: + mode: true + group: + name: myapp + parameters: + database-name: wordpress + mysql-user: wordpress + namespace: wordpress + +parameters: +- name: wordpress-name + type: string + default: porter-ci-wordpress + env: WORDPRESS_NAME +- name: wordpress-password + type: string + sensitive: true + applyTo: + - install + - upgrade +- name: namespace + type: string + default: 'wordpress' + +install: + - exec: + command: ./helpers.sh + arguments: + - install + +upgrade: + - exec: + command: ./helpers.sh + arguments: + - install + +ping: + - exec: + description: "Ping" + command: ./helpers.sh + arguments: + - ping + +uninstall: + - exec: + command: echo + arguments: + - uninstall wordpress + +outputs: + - name: wordpress-password + description: "The Wordpress installation password" + type: string + default: "default-password" + applyTo: + - "install" + - "upgrade" + sensitive: true + path: /cnab/app/outputs/wordpress-password diff --git a/pkg/cnab/config-adapter/adapter_test.go b/pkg/cnab/config-adapter/adapter_test.go index b432c5b3a..5aaf76fb4 100644 --- a/pkg/cnab/config-adapter/adapter_test.go +++ b/pkg/cnab/config-adapter/adapter_test.go @@ -677,8 +677,7 @@ func TestManifestConverter_generateDependenciesv2(t *testing.T) { Bundle: "getporter/azure-mysql:5.7", Version: "5.7.x", Interface: &depsv2ext.DependencyInterface{ - ID: "https://porter.sh/interfaces/#mysql", - Reference: "getporter/mysql-spec:5.7", + ID: "https://porter.sh/interfaces/#mysql", Document: depsv2ext.DependencyInterfaceDocument{ Outputs: map[string]bundle.Output{ "myoutput": { diff --git a/pkg/cnab/config-adapter/testdata/porter-with-depsv2.yaml b/pkg/cnab/config-adapter/testdata/porter-with-depsv2.yaml index 8b8ff3b8b..741ebf1ae 100644 --- a/pkg/cnab/config-adapter/testdata/porter-with-depsv2.yaml +++ b/pkg/cnab/config-adapter/testdata/porter-with-depsv2.yaml @@ -12,7 +12,6 @@ dependencies: version: 5.7.x interface: id: "https://porter.sh/interfaces/#mysql" - reference: "getporter/mysql-spec:5.7" document: outputs: - name: myoutput diff --git a/pkg/cnab/extensions/dependencies/v2/types.go b/pkg/cnab/extensions/dependencies/v2/types.go index 70e13f71f..14b2e779a 100644 --- a/pkg/cnab/extensions/dependencies/v2/types.go +++ b/pkg/cnab/extensions/dependencies/v2/types.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "regexp" + "sort" "github.com/cnabio/cnab-go/bundle" ) @@ -251,3 +252,27 @@ type DependencyInterfaceDocument struct { // Credentials defined on the bundle interface Credentials map[string]bundle.Credential `json:"credentials,omitempty" mapstructure:"credentials,omitempty"` } + +// IsEmpty reports whether the document defines no outputs, parameters, or +// credentials. +func (d DependencyInterfaceDocument) IsEmpty() bool { + return len(d.Outputs) == 0 && len(d.Parameters) == 0 && len(d.Credentials) == 0 +} + +// Names returns the sorted names of the outputs, parameters, and +// credentials defined on this document. +func (d DependencyInterfaceDocument) Names() (outputs, parameters, credentials []string) { + outputs = mapKeys(d.Outputs) + parameters = mapKeys(d.Parameters) + credentials = mapKeys(d.Credentials) + return outputs, parameters, credentials +} + +func mapKeys[V any](m map[string]V) []string { + names := make([]string, 0, len(m)) + for name := range m { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/pkg/cnab/extensions/dependencies/v2/types_test.go b/pkg/cnab/extensions/dependencies/v2/types_test.go index 2dfdd4019..5d775b2fe 100644 --- a/pkg/cnab/extensions/dependencies/v2/types_test.go +++ b/pkg/cnab/extensions/dependencies/v2/types_test.go @@ -4,6 +4,7 @@ import ( "testing" "get.porter.sh/porter/tests" + "github.com/cnabio/cnab-go/bundle" "github.com/stretchr/testify/require" ) @@ -151,3 +152,47 @@ func TestParseAllDependencySources(t *testing.T) { }) } } + +func TestDependencyInterfaceDocument_IsEmpty(t *testing.T) { + t.Parallel() + + require.True(t, DependencyInterfaceDocument{}.IsEmpty(), "a zero-value document should be empty") + + require.False(t, DependencyInterfaceDocument{ + Outputs: map[string]bundle.Output{"connstr": {}}, + }.IsEmpty(), "a document with an output should not be empty") + + require.False(t, DependencyInterfaceDocument{ + Parameters: map[string]bundle.Parameter{"logLevel": {}}, + }.IsEmpty(), "a document with a parameter should not be empty") + + require.False(t, DependencyInterfaceDocument{ + Credentials: map[string]bundle.Credential{"token": {}}, + }.IsEmpty(), "a document with a credential should not be empty") +} + +func TestDependencyInterfaceDocument_Names(t *testing.T) { + t.Parallel() + + doc := DependencyInterfaceDocument{ + Outputs: map[string]bundle.Output{ + "connstr": {}, "port": {}, + }, + Parameters: map[string]bundle.Parameter{ + "logLevel": {}, + }, + Credentials: map[string]bundle.Credential{ + "token": {}, "apiKey": {}, + }, + } + + outputs, parameters, credentials := doc.Names() + require.Equal(t, []string{"connstr", "port"}, outputs) + require.Equal(t, []string{"logLevel"}, parameters) + require.Equal(t, []string{"apiKey", "token"}, credentials) + + emptyOutputs, emptyParameters, emptyCredentials := DependencyInterfaceDocument{}.Names() + require.Empty(t, emptyOutputs) + require.Empty(t, emptyParameters) + require.Empty(t, emptyCredentials) +} diff --git a/pkg/cnab/interface_match.go b/pkg/cnab/interface_match.go new file mode 100644 index 000000000..41fa33ce1 --- /dev/null +++ b/pkg/cnab/interface_match.go @@ -0,0 +1,114 @@ +package cnab + +import "sort" + +// InterfaceRequirement is the set of capability names, by name only (v1 -- +// no schema/type checking, no well-known-identifier matching, see #2650), +// that a candidate must provide to satisfy a bundle interface. +type InterfaceRequirement struct { + Outputs []string + Parameters []string + Credentials []string +} + +// InterfaceCandidate is the set of capability names a candidate actually +// offers. It may be built from a real bundle definition +// (NewInterfaceCandidateFromBundle) or, when no bundle definition is +// available (e.g. matching against an already-installed installation's +// recorded outputs), constructed directly from whatever names are known. +type InterfaceCandidate struct { + Outputs []string + Parameters []string + Credentials []string +} + +// InterfaceMatchMode controls which capability categories must match, +// corresponding to the resolution contexts described in PEP003 (see #2686): +type InterfaceMatchMode int + +const ( + // InterfaceMatchOutputsOnly requires only outputs to match. Used when + // resolving against an arbitrary/user-supplied bundle (unmatched + // credential/parameter mappings are ignored) or an already-installed + // installation (only its outputs are known/relevant). + InterfaceMatchOutputsOnly InterfaceMatchMode = iota + + // InterfaceMatchFull requires outputs, parameters, and credentials to + // all match. Used when resolving the dependency's default declared + // bundle/version, where the dependency's own credential/parameter + // mappings are meant specifically for that bundle. + InterfaceMatchFull +) + +// InterfaceMatchResult is the outcome of evaluating a candidate against a +// requirement. Satisfied is the single bool callers branch on; the Missing* +// fields exist purely for diagnostics (error/warning messages), not as a +// score -- matching is binary, matching PEP003's deterministic resolution +// precedence (see #2626). +type InterfaceMatchResult struct { + Satisfied bool + + MissingOutputs []string + MissingParameters []string + MissingCredentials []string +} + +// NewInterfaceCandidateFromBundle builds an InterfaceCandidate from a real +// bundle definition's own outputs, parameters, and credentials. +func NewInterfaceCandidateFromBundle(b ExtendedBundle) InterfaceCandidate { + return InterfaceCandidate{ + Outputs: mapKeys(b.Outputs), + Parameters: mapKeys(b.Parameters), + Credentials: mapKeys(b.Credentials), + } +} + +func mapKeys[V any](m map[string]V) []string { + names := make([]string, 0, len(m)) + for name := range m { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// EvaluateInterfaceMatch reports whether candidate satisfies required, per +// mode. It is a binary predicate (satisfies / doesn't) -- deliberately not +// a score, matching PEP003's fully deterministic resolution precedence. +func EvaluateInterfaceMatch(candidate InterfaceCandidate, required InterfaceRequirement, mode InterfaceMatchMode) InterfaceMatchResult { + result := InterfaceMatchResult{ + MissingOutputs: missingNames(required.Outputs, candidate.Outputs), + } + + if mode == InterfaceMatchFull { + result.MissingParameters = missingNames(required.Parameters, candidate.Parameters) + result.MissingCredentials = missingNames(required.Credentials, candidate.Credentials) + } + + result.Satisfied = len(result.MissingOutputs) == 0 && + len(result.MissingParameters) == 0 && + len(result.MissingCredentials) == 0 + + return result +} + +// missingNames returns the entries in required that are absent from +// available, or nil when none are missing. +func missingNames(required, available []string) []string { + if len(required) == 0 { + return nil + } + + availableSet := make(map[string]bool, len(available)) + for _, name := range available { + availableSet[name] = true + } + + var missing []string + for _, name := range required { + if !availableSet[name] { + missing = append(missing, name) + } + } + return missing +} diff --git a/pkg/cnab/interface_match_test.go b/pkg/cnab/interface_match_test.go new file mode 100644 index 000000000..53d44fafd --- /dev/null +++ b/pkg/cnab/interface_match_test.go @@ -0,0 +1,138 @@ +package cnab + +import ( + "testing" + + "github.com/cnabio/cnab-go/bundle" + "github.com/stretchr/testify/require" +) + +func TestEvaluateInterfaceMatch(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + candidate InterfaceCandidate + required InterfaceRequirement + mode InterfaceMatchMode + want InterfaceMatchResult + }{ + { + name: "empty requirement is always satisfied", + required: InterfaceRequirement{}, + mode: InterfaceMatchFull, + want: InterfaceMatchResult{Satisfied: true}, + }, + { + name: "outputs only: candidate has the required output", + candidate: InterfaceCandidate{Outputs: []string{"connstr"}}, + required: InterfaceRequirement{Outputs: []string{"connstr"}}, + mode: InterfaceMatchOutputsOnly, + want: InterfaceMatchResult{Satisfied: true}, + }, + { + name: "outputs only: candidate is missing the required output", + candidate: InterfaceCandidate{Outputs: []string{"port"}}, + required: InterfaceRequirement{Outputs: []string{"connstr"}}, + mode: InterfaceMatchOutputsOnly, + want: InterfaceMatchResult{ + Satisfied: false, + MissingOutputs: []string{"connstr"}, + }, + }, + { + name: "outputs only: missing parameters and credentials are ignored", + candidate: InterfaceCandidate{ + Outputs: []string{"connstr"}, + }, + required: InterfaceRequirement{ + Outputs: []string{"connstr"}, + Parameters: []string{"logLevel"}, + Credentials: []string{"token"}, + }, + mode: InterfaceMatchOutputsOnly, + want: InterfaceMatchResult{Satisfied: true}, + }, + { + name: "full mode: all present", + candidate: InterfaceCandidate{ + Outputs: []string{"connstr"}, + Parameters: []string{"logLevel"}, + Credentials: []string{"token"}, + }, + required: InterfaceRequirement{ + Outputs: []string{"connstr"}, + Parameters: []string{"logLevel"}, + Credentials: []string{"token"}, + }, + mode: InterfaceMatchFull, + want: InterfaceMatchResult{Satisfied: true}, + }, + { + name: "full mode: missing parameter fails the match", + candidate: InterfaceCandidate{ + Outputs: []string{"connstr"}, + Credentials: []string{"token"}, + }, + required: InterfaceRequirement{ + Outputs: []string{"connstr"}, + Parameters: []string{"logLevel"}, + Credentials: []string{"token"}, + }, + mode: InterfaceMatchFull, + want: InterfaceMatchResult{ + Satisfied: false, + MissingParameters: []string{"logLevel"}, + }, + }, + { + name: "full mode: missing credential fails the match", + candidate: InterfaceCandidate{ + Outputs: []string{"connstr"}, + Parameters: []string{"logLevel"}, + }, + required: InterfaceRequirement{ + Outputs: []string{"connstr"}, + Parameters: []string{"logLevel"}, + Credentials: []string{"token"}, + }, + mode: InterfaceMatchFull, + want: InterfaceMatchResult{ + Satisfied: false, + MissingCredentials: []string{"token"}, + }, + }, + } + + for _, test := range testcases { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got := EvaluateInterfaceMatch(test.candidate, test.required, test.mode) + require.Equal(t, test.want, got) + }) + } +} + +func TestNewInterfaceCandidateFromBundle(t *testing.T) { + t.Parallel() + + bun := ExtendedBundle{Bundle: bundle.Bundle{ + Outputs: map[string]bundle.Output{ + "port": {}, "connstr": {}, + }, + Parameters: map[string]bundle.Parameter{ + "logLevel": {}, + }, + Credentials: map[string]bundle.Credential{ + "token": {}, "apiKey": {}, + }, + }} + + got := NewInterfaceCandidateFromBundle(bun) + require.Equal(t, InterfaceCandidate{ + Outputs: []string{"connstr", "port"}, + Parameters: []string{"logLevel"}, + Credentials: []string{"apiKey", "token"}, + }, got) +} diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go index b861a38ec..d7ffc9325 100644 --- a/pkg/manifest/manifest.go +++ b/pkg/manifest/manifest.go @@ -1086,6 +1086,10 @@ func (d *Dependency) Validate(cxt *portercontext.Context) error { return fmt.Errorf("reference for dependency %q can specify only a repository, without a digest or tag, when a version constraint is specified", d.Name) } + if iface := d.Bundle.Interface; iface != nil && iface.Reference != "" && iface.Document != nil { + return fmt.Errorf("bundle interface for dependency %q can specify only a reference or a document, not both", d.Name) + } + return nil } diff --git a/pkg/manifest/manifest_test.go b/pkg/manifest/manifest_test.go index 79c0b5e64..9d1fc0073 100644 --- a/pkg/manifest/manifest_test.go +++ b/pkg/manifest/manifest_test.go @@ -1251,6 +1251,38 @@ func TestManifest_DetermineDependenciesExtensionUsed(t *testing.T) { }) } +func TestDependency_Validate_BundleInterfaceReferenceAndDocument(t *testing.T) { + cxt := portercontext.NewTestContext(t) + + d := &Dependency{ + Name: "mysql", + Bundle: BundleCriteria{Reference: "mysql:5.7"}, + } + + t.Run("reference only is valid", func(t *testing.T) { + d.Bundle.Interface = &BundleInterface{Reference: "mysql-interface:5.7"} + require.NoError(t, d.Validate(cxt.Context)) + }) + + t.Run("document only is valid", func(t *testing.T) { + d.Bundle.Interface = &BundleInterface{Document: &BundleInterfaceDocument{ + Outputs: OutputDefinitions{"connstr": {Name: "connstr"}}, + }} + require.NoError(t, d.Validate(cxt.Context)) + }) + + t.Run("both reference and document is rejected", func(t *testing.T) { + d.Bundle.Interface = &BundleInterface{ + Reference: "mysql-interface:5.7", + Document: &BundleInterfaceDocument{ + Outputs: OutputDefinitions{"connstr": {Name: "connstr"}}, + }, + } + err := d.Validate(cxt.Context) + require.ErrorContains(t, err, "bundle interface for dependency \"mysql\" can specify only a reference or a document, not both") + }) +} + func TestExpandPersistentParameters(t *testing.T) { newCfg := func(t *testing.T, flags ...experimental.FeatureFlags) *config.Config { cfg := config.NewTestConfig(t) diff --git a/pkg/porter/dependency_graph_builder.go b/pkg/porter/dependency_graph_builder.go index c7bae2d74..03b9f636b 100644 --- a/pkg/porter/dependency_graph_builder.go +++ b/pkg/porter/dependency_graph_builder.go @@ -2,12 +2,14 @@ package porter import ( "context" + "errors" "fmt" "sort" "get.porter.sh/porter/pkg/cnab" cnabtooci "get.porter.sh/porter/pkg/cnab/cnab-to-oci" v2 "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v2" + "get.porter.sh/porter/pkg/experimental" ) // GraphBuilder resolves the full, transitive dependency graph for a bundle. @@ -186,22 +188,40 @@ func (b *GraphBuilder) expandNode( g.Nodes[childKey] = node // Prefer reusing an already-installed installation over pulling a - // new instance of the dependency's bundle -- but only for the plain - // pinned-version case (no declared bundle interface): matching a - // candidate against a declared interface needs #2626, which isn't - // implemented yet, so a dependency that declares one always falls - // through to the pull below. A lookup error is treated the same as - // "no match found" rather than aborting the node, so this is purely - // a best-effort optimization on top of the existing pull path. - if hasDep && dep.Interface == nil { - required := requiredOutputNames(lock.Alias, dep, refsByToAlias) - if inst, err := findExistingInstallation(ctx, b.porter, opts.Namespace, lock, required); err == nil && inst != nil { - node.ResolvedInstallation = inst - if shareable { - g.sharedByContent[ck] = childKey - } + // new instance of the dependency's bundle. A declared bundle + // interface no longer unconditionally blocks this (#2626): a + // dependency with no declared interface is always eligible; + // composeRequiredInterface folds in whatever outputs + // interface.reference/interface.document add on top of the outputs + // the parent bundle actually uses -- gated behind + // FlagDependenciesV2 so a dependency declaring an interface still + // always falls through to the pull below when the experimental + // flag is disabled, exactly as it did before #2626. + // + // composeRequiredInterface returning an error is handled two ways: + // errInterfaceReferenceAndDocument (both reference and document + // set on the interface) is an authoring bug, not a transient + // failure, so it aborts the node the same way a pull failure does, + // below. Any other error (e.g. a failed interface.Reference pull) + // is treated the same as "no match found" rather than aborting the + // node, so this remains a best-effort optimization on top of the + // existing pull path. + if hasDep && (dep.Interface == nil || b.porter.IsFeatureEnabled(experimental.FlagDependenciesV2)) { + required, err := b.composeRequiredInterface(ctx, lock.Alias, dep, refsByToAlias, opts) + if err != nil && errors.Is(err, errInterfaceReferenceAndDocument) { + node.ResolutionFailed = true + node.ResolutionError = err.Error() continue } + if err == nil { + if inst, err := findExistingInstallation(ctx, b.porter, opts.Namespace, lock, required.Outputs); err == nil && inst != nil { + node.ResolvedInstallation = inst + if shareable { + g.sharedByContent[ck] = childKey + } + continue + } + } } childBun, err := b.pullDependencyBundle(ctx, lock.Reference, opts) diff --git a/pkg/porter/dependency_graph_test.go b/pkg/porter/dependency_graph_test.go index d021f12b6..7010a7d5b 100644 --- a/pkg/porter/dependency_graph_test.go +++ b/pkg/porter/dependency_graph_test.go @@ -10,6 +10,7 @@ import ( cnabtooci "get.porter.sh/porter/pkg/cnab/cnab-to-oci" depsv1 "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v1" v2 "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v2" + "get.porter.sh/porter/pkg/experimental" "get.porter.sh/porter/pkg/storage" "github.com/cnabio/cnab-go/bundle" "github.com/stretchr/testify/assert" @@ -798,7 +799,7 @@ func TestGraphBuilder_ExistingInstallation(t *testing.T) { assert.Empty(t, deps[0].Dependencies) }) - t.Run("dependency declaring an interface always pulls", func(t *testing.T) { + t.Run("dependency declaring an interface always pulls when the experimental flag is disabled", func(t *testing.T) { t.Parallel() root := v2TestBundle("root", map[string]v2.Dependency{ @@ -820,6 +821,188 @@ func TestGraphBuilder_ExistingInstallation(t *testing.T) { assert.Empty(t, deps[0].ResolvedInstallation) }) + t.Run("dependency declaring an ID-only interface reuses when the experimental flag is enabled", func(t *testing.T) { + t.Parallel() + + // db declares an interface but promotes no outputs to the parent + // and nothing is wired from it, so composeRequiredInterface (#2626) + // contributes nothing beyond the empty requiredOutputNames -- the + // candidate's exact bundle content already guarantees it satisfies + // whatever ID the dependency declares (findExistingInstallation + // only ever considers candidates running that same content), so + // this reuses instead of pulling once the flag is on. + root := v2TestBundle("root", map[string]v2.Dependency{ + "db": {Bundle: dbRef, Sharing: shared, Interface: &v2.DependencyInterface{ID: "mysql"}}, + }) + + p := NewTestPorter(t) + p.SetExperimentalFlags(experimental.FlagDependenciesV2) + defer p.Close() + p.TestInstallations.CreateInstallation(newCandidate("", nil)) + + builder := NewGraphBuilder(p.Porter, 10) + graph, err := builder.BuildDependencyGraph(context.Background(), root, ExplainOpts{MaxDependencyDepth: 10}) + require.NoError(t, err) + + deps := graphToInspectableDependencies(graph, graph.Root, 0) + require.Len(t, deps, 1) + assert.False(t, deps[0].ResolutionFailed) + assert.Equal(t, "/db", deps[0].ResolvedInstallation) + }) + + t.Run("dependency with interface.document requiring an output the candidate lacks falls back to pull", func(t *testing.T) { + t.Parallel() + + root := v2TestBundle("root", map[string]v2.Dependency{ + "db": { + Bundle: dbRef, + Sharing: shared, + Interface: &v2.DependencyInterface{ + Document: v2.DependencyInterfaceDocument{ + Outputs: map[string]bundle.Output{"port": {}}, + }, + }, + }, + }) + + p := NewTestPorter(t) + p.SetExperimentalFlags(experimental.FlagDependenciesV2) + defer p.Close() + // candidate never recorded "port", the output interface.document requires + p.TestInstallations.CreateInstallation(newCandidate("", nil)) + p.TestRegistry.MockPullBundle = newMockPullBundle(map[string]cnab.ExtendedBundle{dbRef: leafTestBundle("mysql")}) + + builder := NewGraphBuilder(p.Porter, 10) + graph, err := builder.BuildDependencyGraph(context.Background(), root, ExplainOpts{MaxDependencyDepth: 10}) + require.NoError(t, err) + + deps := graphToInspectableDependencies(graph, graph.Root, 0) + require.Len(t, deps, 1) + assert.False(t, deps[0].ResolutionFailed) + assert.Empty(t, deps[0].ResolvedInstallation) + }) + + t.Run("dependency with interface.document reuses when the candidate has all required outputs", func(t *testing.T) { + t.Parallel() + + root := v2TestBundle("root", map[string]v2.Dependency{ + "db": { + Bundle: dbRef, + Sharing: shared, + Interface: &v2.DependencyInterface{ + Document: v2.DependencyInterfaceDocument{ + Outputs: map[string]bundle.Output{"port": {}}, + }, + }, + }, + }) + + p := NewTestPorter(t) + p.SetExperimentalFlags(experimental.FlagDependenciesV2) + defer p.Close() + candidate := p.TestInstallations.CreateInstallation(newCandidate("", nil)) + p.TestInstallations.CreateOutput(storage.Output{Namespace: candidate.Namespace, Installation: candidate.Name, Name: "port", ResultID: "1"}) + + builder := NewGraphBuilder(p.Porter, 10) + graph, err := builder.BuildDependencyGraph(context.Background(), root, ExplainOpts{MaxDependencyDepth: 10}) + require.NoError(t, err) + + deps := graphToInspectableDependencies(graph, graph.Root, 0) + require.Len(t, deps, 1) + assert.False(t, deps[0].ResolutionFailed) + assert.Equal(t, "/db", deps[0].ResolvedInstallation) + }) + + t.Run("dependency with interface.reference pulls the reference to determine required outputs", func(t *testing.T) { + t.Parallel() + + const interfaceRef = "localhost:5000/mysql-interface:v1.0.0" + + root := v2TestBundle("root", map[string]v2.Dependency{ + "db": { + Bundle: dbRef, + Sharing: shared, + Interface: &v2.DependencyInterface{Reference: interfaceRef}, + }, + }) + + p := NewTestPorter(t) + p.SetExperimentalFlags(experimental.FlagDependenciesV2) + defer p.Close() + candidate := p.TestInstallations.CreateInstallation(newCandidate("", nil)) + p.TestInstallations.CreateOutput(storage.Output{Namespace: candidate.Namespace, Installation: candidate.Name, Name: "port", ResultID: "1"}) + p.TestRegistry.MockPullBundle = newMockPullBundle(map[string]cnab.ExtendedBundle{ + interfaceRef: {Bundle: bundle.Bundle{Name: "mysql-interface", Version: "1.0.0", Outputs: map[string]bundle.Output{"port": {}}}}, + }) + + builder := NewGraphBuilder(p.Porter, 10) + graph, err := builder.BuildDependencyGraph(context.Background(), root, ExplainOpts{MaxDependencyDepth: 10}) + require.NoError(t, err) + + deps := graphToInspectableDependencies(graph, graph.Root, 0) + require.Len(t, deps, 1) + assert.False(t, deps[0].ResolutionFailed) + assert.Equal(t, "/db", deps[0].ResolvedInstallation) + }) + + t.Run("composeRequiredInterface pull error falls back to the normal dependency pull", func(t *testing.T) { + t.Parallel() + + root := v2TestBundle("root", map[string]v2.Dependency{ + "db": { + Bundle: dbRef, + Sharing: shared, + Interface: &v2.DependencyInterface{Reference: "localhost:5000/missing-interface:v1.0.0"}, + }, + }) + + p := NewTestPorter(t) + p.SetExperimentalFlags(experimental.FlagDependenciesV2) + defer p.Close() + p.TestInstallations.CreateInstallation(newCandidate("", nil)) + // dbRef itself resolves fine; only the interface reference is unmocked and errors. + p.TestRegistry.MockPullBundle = newMockPullBundle(map[string]cnab.ExtendedBundle{dbRef: leafTestBundle("mysql")}) + + builder := NewGraphBuilder(p.Porter, 10) + graph, err := builder.BuildDependencyGraph(context.Background(), root, ExplainOpts{MaxDependencyDepth: 10}) + require.NoError(t, err) + + deps := graphToInspectableDependencies(graph, graph.Root, 0) + require.Len(t, deps, 1) + assert.False(t, deps[0].ResolutionFailed) + assert.Empty(t, deps[0].ResolvedInstallation) + }) + + t.Run("interface.reference and interface.document both set fails the node, no fallback pull attempted", func(t *testing.T) { + t.Parallel() + + root := v2TestBundle("root", map[string]v2.Dependency{ + "db": { + Bundle: dbRef, + Sharing: shared, + Interface: &v2.DependencyInterface{ + Reference: "localhost:5000/mysql-interface:v1.0.0", + Document: v2.DependencyInterfaceDocument{Outputs: map[string]bundle.Output{"port": {}}}, + }, + }, + }) + + p := NewTestPorter(t) + p.SetExperimentalFlags(experimental.FlagDependenciesV2) + defer p.Close() + p.TestInstallations.CreateInstallation(newCandidate("", nil)) + p.TestRegistry.MockPullBundle = failIfPulled(t) + + builder := NewGraphBuilder(p.Porter, 10) + graph, err := builder.BuildDependencyGraph(context.Background(), root, ExplainOpts{MaxDependencyDepth: 10}) + require.NoError(t, err) + + deps := graphToInspectableDependencies(graph, graph.Root, 0) + require.Len(t, deps, 1) + assert.True(t, deps[0].ResolutionFailed) + assert.Contains(t, deps[0].ResolutionError, "reference and a document") + }) + t.Run("non-shareable dependency always pulls", func(t *testing.T) { t.Parallel() diff --git a/pkg/porter/dependency_installation_resolver.go b/pkg/porter/dependency_installation_resolver.go index c6e9e7acc..493f2f4d5 100644 --- a/pkg/porter/dependency_installation_resolver.go +++ b/pkg/porter/dependency_installation_resolver.go @@ -52,10 +52,14 @@ func requiredOutputNames(alias string, dep v2.Dependency, refsByToAlias map[stri // instead of pulling and installing a new instance of its bundle. Returns // nil (no error) when no candidate satisfies every rule. // -// This only handles the "default implementation, no bundle interface -// declared" case (see dependency_graph_builder.go's caller) -- matching a -// candidate against a declared bundle interface is out of scope until #2626 -// lands. +// required may include names contributed by a declared bundle interface +// (see composeRequiredInterface), but only outputs are ever checked here +// regardless of what the interface declares -- this is the "existing +// installation" resolution context from PEP003 (see #2686), where +// credentials/parameters aren't re-evaluated because the installation is +// already running. Whether that should also hold for upgrade/uninstall/ +// custom actions against an existing installation is an open question +// tracked by #2686, not resolved here. func findExistingInstallation(ctx context.Context, p *Porter, namespace string, lock cnab.DependencyLock, required []string) (*storage.Installation, error) { if !lock.SharingMode { // A dependency with sharing disabled must never reuse an @@ -162,11 +166,15 @@ func hasRequiredOutputs(ctx context.Context, p *Porter, candidate storage.Instal return false } - for _, name := range required { - if _, ok := outputs.GetByName(name); !ok { - return false - } + available := make([]string, 0, outputs.Len()) + for _, o := range outputs.Value() { + available = append(available, o.Name) } - return true + result := cnab.EvaluateInterfaceMatch( + cnab.InterfaceCandidate{Outputs: available}, + cnab.InterfaceRequirement{Outputs: required}, + cnab.InterfaceMatchOutputsOnly, + ) + return result.Satisfied } diff --git a/pkg/porter/dependency_interface_resolver.go b/pkg/porter/dependency_interface_resolver.go new file mode 100644 index 000000000..7a7c88cb9 --- /dev/null +++ b/pkg/porter/dependency_interface_resolver.go @@ -0,0 +1,95 @@ +package porter + +import ( + "context" + "errors" + "sort" + + "get.porter.sh/porter/pkg/cnab" + v2 "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v2" +) + +// errInterfaceReferenceAndDocument is returned by composeRequiredInterface +// when a dependency declares both interface.reference and +// interface.document -- manifest.BundleInterface's own doc comment already +// states these are mutually exclusive, but nothing has enforced it up to +// now (see #2626). Unlike a transient interface.Reference pull failure, +// this is an authoring bug and must surface as a real failure rather than +// being treated as a best-effort "no match, fall back to pull." +var errInterfaceReferenceAndDocument = errors.New("dependency interface declares both a reference and a document; only one may be set") + +// composeRequiredInterface computes the full InterfaceRequirement for a v2 +// dependency: the union of outputs the parent bundle actually uses from it +// (requiredOutputNames) and whatever interface.Reference or +// interface.Document adds on top, per PEP003's bundle interface +// composition (https://github.com/getporter/proposals/blob/main/pep/003-advanced-dependencies.md#bundle-interfaces). +// +// Callers must check experimental.FlagDependenciesV2 before invoking this +// for a dependency with a non-nil Interface -- this function does not +// re-check it, matching the codebase's existing convention of gating +// experimental behavior at the call site (see dependency_graph_builder.go). +func (b *GraphBuilder) composeRequiredInterface(ctx context.Context, alias string, dep v2.Dependency, refsByToAlias map[string][]wiringRef, opts ExplainOpts) (cnab.InterfaceRequirement, error) { + required := cnab.InterfaceRequirement{ + Outputs: requiredOutputNames(alias, dep, refsByToAlias), + } + + if dep.Interface == nil { + return required, nil + } + + hasReference := dep.Interface.Reference != "" + hasDocument := !dep.Interface.Document.IsEmpty() + + switch { + case hasReference && hasDocument: + return cnab.InterfaceRequirement{}, errInterfaceReferenceAndDocument + + case hasReference: + referenceBun, err := b.pullDependencyBundle(ctx, dep.Interface.Reference, opts) + if err != nil { + return cnab.InterfaceRequirement{}, err + } + candidate := cnab.NewInterfaceCandidateFromBundle(referenceBun) + required.Outputs = unionSorted(required.Outputs, candidate.Outputs) + required.Parameters = unionSorted(required.Parameters, candidate.Parameters) + required.Credentials = unionSorted(required.Credentials, candidate.Credentials) + + case hasDocument: + outputs, parameters, credentials := dep.Interface.Document.Names() + required.Outputs = unionSorted(required.Outputs, outputs) + required.Parameters = unionSorted(required.Parameters, parameters) + required.Credentials = unionSorted(required.Credentials, credentials) + } + + // ID-only (neither Reference nor Document set): nothing structural to + // add. The ID itself isn't evaluated here -- see + // cnab.EvaluateInterfaceMatch's doc comment for why, and #2686 for + // where a whole-interface ID shortcut would apply. + + return required, nil +} + +// unionSorted returns the sorted, deduplicated union of a and b. +func unionSorted(a, b []string) []string { + if len(a) == 0 && len(b) == 0 { + return nil + } + + seen := make(map[string]bool, len(a)+len(b)) + merged := make([]string, 0, len(a)+len(b)) + for _, name := range a { + if !seen[name] { + seen[name] = true + merged = append(merged, name) + } + } + for _, name := range b { + if !seen[name] { + seen[name] = true + merged = append(merged, name) + } + } + + sort.Strings(merged) + return merged +} diff --git a/pkg/porter/dependency_interface_resolver_test.go b/pkg/porter/dependency_interface_resolver_test.go new file mode 100644 index 000000000..eb09eb25d --- /dev/null +++ b/pkg/porter/dependency_interface_resolver_test.go @@ -0,0 +1,156 @@ +package porter + +import ( + "context" + "errors" + "testing" + + "get.porter.sh/porter/pkg/cnab" + cnabtooci "get.porter.sh/porter/pkg/cnab/cnab-to-oci" + v2 "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v2" + "github.com/cnabio/cnab-go/bundle" + "github.com/stretchr/testify/require" +) + +// failIfPulled returns a MockPullBundle that fails the test immediately if +// invoked, for asserting that composeRequiredInterface didn't attempt a +// pull in cases where it shouldn't need to. +func failIfPulled(t *testing.T) func(context.Context, cnab.OCIReference, cnabtooci.RegistryOptions) (cnab.BundleReference, error) { + t.Helper() + return func(_ context.Context, ref cnab.OCIReference, _ cnabtooci.RegistryOptions) (cnab.BundleReference, error) { + t.Fatalf("unexpected pull of %s", ref.String()) + return cnab.BundleReference{}, nil + } +} + +func TestGraphBuilder_ComposeRequiredInterface(t *testing.T) { + t.Parallel() + + const dbRef = "localhost:5000/mysql:v1.0.0" + + baseDep := func(transform func(d *v2.Dependency)) v2.Dependency { + d := v2.Dependency{Bundle: dbRef, Outputs: map[string]string{"connstr": "${outputs.connstr}"}} + if transform != nil { + transform(&d) + } + return d + } + + t.Run("nil interface: requirement is exactly requiredOutputNames, no pull attempted", func(t *testing.T) { + t.Parallel() + + p := NewTestPorter(t) + defer p.Close() + p.TestRegistry.MockPullBundle = failIfPulled(t) + + builder := NewGraphBuilder(p.Porter, 10) + got, err := builder.composeRequiredInterface(context.Background(), "db", baseDep(nil), nil, ExplainOpts{}) + require.NoError(t, err) + require.Equal(t, cnab.InterfaceRequirement{Outputs: []string{"connstr"}}, got) + }) + + t.Run("document only: unions document names, no pull attempted", func(t *testing.T) { + t.Parallel() + + p := NewTestPorter(t) + defer p.Close() + p.TestRegistry.MockPullBundle = failIfPulled(t) + + dep := baseDep(func(d *v2.Dependency) { + d.Interface = &v2.DependencyInterface{Document: v2.DependencyInterfaceDocument{ + Outputs: map[string]bundle.Output{"port": {}}, + Parameters: map[string]bundle.Parameter{"logLevel": {}}, + Credentials: map[string]bundle.Credential{"token": {}}, + }} + }) + + builder := NewGraphBuilder(p.Porter, 10) + got, err := builder.composeRequiredInterface(context.Background(), "db", dep, nil, ExplainOpts{}) + require.NoError(t, err) + require.Equal(t, cnab.InterfaceRequirement{ + Outputs: []string{"connstr", "port"}, + Parameters: []string{"logLevel"}, + Credentials: []string{"token"}, + }, got) + }) + + t.Run("reference only: pulls the referenced bundle and unions its names", func(t *testing.T) { + t.Parallel() + + const interfaceRef = "localhost:5000/mysql-interface:v1.0.0" + + p := NewTestPorter(t) + defer p.Close() + p.TestRegistry.MockPullBundle = newMockPullBundle(map[string]cnab.ExtendedBundle{ + interfaceRef: {Bundle: bundle.Bundle{ + Name: "mysql-interface", + Version: "1.0.0", + Outputs: map[string]bundle.Output{"port": {}}, + }}, + }) + + dep := baseDep(func(d *v2.Dependency) { + d.Interface = &v2.DependencyInterface{Reference: interfaceRef} + }) + + builder := NewGraphBuilder(p.Porter, 10) + got, err := builder.composeRequiredInterface(context.Background(), "db", dep, nil, ExplainOpts{}) + require.NoError(t, err) + require.Equal(t, cnab.InterfaceRequirement{ + Outputs: []string{"connstr", "port"}, + }, got) + }) + + t.Run("reference and document both set: returns the sentinel error, no pull attempted", func(t *testing.T) { + t.Parallel() + + p := NewTestPorter(t) + defer p.Close() + p.TestRegistry.MockPullBundle = failIfPulled(t) + + dep := baseDep(func(d *v2.Dependency) { + d.Interface = &v2.DependencyInterface{ + Reference: "localhost:5000/mysql-interface:v1.0.0", + Document: v2.DependencyInterfaceDocument{Outputs: map[string]bundle.Output{"port": {}}}, + } + }) + + builder := NewGraphBuilder(p.Porter, 10) + _, err := builder.composeRequiredInterface(context.Background(), "db", dep, nil, ExplainOpts{}) + require.ErrorIs(t, err, errInterfaceReferenceAndDocument) + }) + + t.Run("ID only: requirement is exactly requiredOutputNames, no pull attempted", func(t *testing.T) { + t.Parallel() + + p := NewTestPorter(t) + defer p.Close() + p.TestRegistry.MockPullBundle = failIfPulled(t) + + dep := baseDep(func(d *v2.Dependency) { + d.Interface = &v2.DependencyInterface{ID: "mysql"} + }) + + builder := NewGraphBuilder(p.Porter, 10) + got, err := builder.composeRequiredInterface(context.Background(), "db", dep, nil, ExplainOpts{}) + require.NoError(t, err) + require.Equal(t, cnab.InterfaceRequirement{Outputs: []string{"connstr"}}, got) + }) + + t.Run("reference pull failure propagates as a plain error, not the sentinel", func(t *testing.T) { + t.Parallel() + + p := NewTestPorter(t) + defer p.Close() + p.TestRegistry.MockPullBundle = newMockPullBundle(map[string]cnab.ExtendedBundle{}) + + dep := baseDep(func(d *v2.Dependency) { + d.Interface = &v2.DependencyInterface{Reference: "localhost:5000/missing-interface:v1.0.0"} + }) + + builder := NewGraphBuilder(p.Porter, 10) + _, err := builder.composeRequiredInterface(context.Background(), "db", dep, nil, ExplainOpts{}) + require.Error(t, err) + require.False(t, errors.Is(err, errInterfaceReferenceAndDocument)) + }) +} diff --git a/tests/integration/inspect_bundle_interface_test.go b/tests/integration/inspect_bundle_interface_test.go new file mode 100644 index 000000000..cab54c16a --- /dev/null +++ b/tests/integration/inspect_bundle_interface_test.go @@ -0,0 +1,172 @@ +//go:build integration + +package integration + +import ( + "context" + "os" + "path/filepath" + "testing" + + "get.porter.sh/porter/pkg/experimental" + "get.porter.sh/porter/pkg/porter" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestInspectResolvesExistingInstallationWithBundleInterface verifies #2626 +// end-to-end: a v2 dependency declaring a bundle interface (interface.document +// requiring the "mysql-password" output) resolves to an already-installed, +// compatible installation -- instead of always pulling, which was the only +// behavior before #2626 -- when that installation actually has the required +// output recorded. +func TestInspectResolvesExistingInstallationWithBundleInterface(t *testing.T) { + t.Parallel() + p := porter.NewTestPorter(t) + defer p.Close() + ctx := p.SetupIntegrationTest() + + p.Config.SetExperimentalFlags(experimental.FlagDependenciesV2) + + namespace := p.RandomString(10) + + publishMySQLV2(ctx, p) + // mysql's real install writes /cnab/app/outputs/mysql-password, so the + // resulting installation genuinely satisfies the interface below -- + // no manual output seeding needed. + installMySQLByReference(ctx, p, namespace) + + publishWordpressV2Interface(ctx, p) + + opts := porter.ExplainOpts{} + opts.Reference = "localhost:5000/wordpress-interface:v0.1.4" + opts.Namespace = namespace + opts.ShowDependencies = true + opts.MaxDependencyDepth = 10 + + err := opts.Validate(nil, p.Context) + require.NoError(t, err) + + inspectOutput, err := p.GetInspectOutput(ctx, opts) + require.NoError(t, err) + + require.Len(t, inspectOutput.Dependencies, 1) + mysqlDep := inspectOutput.Dependencies[0] + assert.Equal(t, "mysql", mysqlDep.Alias) + assert.False(t, mysqlDep.ResolutionFailed) + assert.Equal(t, namespace+"/mysql", mysqlDep.ResolvedInstallation) +} + +// TestInspectFallsBackToPullWhenBundleInterfaceOutputMissing verifies the +// negative case for #2626: a bundle interface requiring an output mysql +// never produces (interface.document requires "mysql-connstr", which isn't +// among mysql's declared outputs) is never satisfied by any mysql +// installation, so the dependency is pulled as a new bundle instead of +// being reused. +func TestInspectFallsBackToPullWhenBundleInterfaceOutputMissing(t *testing.T) { + t.Parallel() + p := porter.NewTestPorter(t) + defer p.Close() + ctx := p.SetupIntegrationTest() + + p.Config.SetExperimentalFlags(experimental.FlagDependenciesV2) + + namespace := p.RandomString(10) + + publishMySQLV2(ctx, p) + installMySQLByReference(ctx, p, namespace) + + publishWordpressV2InterfaceMissing(ctx, p) + + opts := porter.ExplainOpts{} + opts.Reference = "localhost:5000/wordpress-interface-missing:v0.1.4" + opts.Namespace = namespace + opts.ShowDependencies = true + opts.MaxDependencyDepth = 10 + + err := opts.Validate(nil, p.Context) + require.NoError(t, err) + + inspectOutput, err := p.GetInspectOutput(ctx, opts) + require.NoError(t, err) + + require.Len(t, inspectOutput.Dependencies, 1) + mysqlDep := inspectOutput.Dependencies[0] + assert.Equal(t, "mysql", mysqlDep.Alias) + assert.False(t, mysqlDep.ResolutionFailed) + assert.Empty(t, mysqlDep.ResolvedInstallation) +} + +// TestInspectResolvesExistingInstallationWithBundleInterfaceReference +// verifies #2626's interface.Reference path end-to-end: a v2 dependency +// whose interface is declared via a separate, published "interface bundle" +// (mysql-interface, which is never installed -- it only exists to declare +// the outputs a bundle must provide) resolves to an already-installed mysql +// installation, the same as interface.document does, but by pulling the +// referenced bundle to determine the required outputs instead of reading +// them inline. +func TestInspectResolvesExistingInstallationWithBundleInterfaceReference(t *testing.T) { + t.Parallel() + p := porter.NewTestPorter(t) + defer p.Close() + ctx := p.SetupIntegrationTest() + + p.Config.SetExperimentalFlags(experimental.FlagDependenciesV2) + + namespace := p.RandomString(10) + + publishMySQLV2(ctx, p) + // mysql's real install writes /cnab/app/outputs/mysql-password, so the + // resulting installation genuinely satisfies mysql-interface's declared + // "mysql-password" output below. + installMySQLByReference(ctx, p, namespace) + + publishBundleDir(ctx, p, "mysql-interface") + publishBundleDir(ctx, p, "wordpressv2-interface-reference") + + opts := porter.ExplainOpts{} + opts.Reference = "localhost:5000/wordpress-interface-reference:v0.1.4" + opts.Namespace = namespace + opts.ShowDependencies = true + opts.MaxDependencyDepth = 10 + + err := opts.Validate(nil, p.Context) + require.NoError(t, err) + + inspectOutput, err := p.GetInspectOutput(ctx, opts) + require.NoError(t, err) + + require.Len(t, inspectOutput.Dependencies, 1) + mysqlDep := inspectOutput.Dependencies[0] + assert.Equal(t, "mysql", mysqlDep.Alias) + assert.False(t, mysqlDep.ResolutionFailed) + assert.Equal(t, namespace+"/mysql", mysqlDep.ResolvedInstallation) +} + +func publishWordpressV2Interface(ctx context.Context, p *porter.TestPorter) { + publishBundleDir(ctx, p, "wordpressv2-interface") +} + +func publishWordpressV2InterfaceMissing(ctx context.Context, p *porter.TestPorter) { + publishBundleDir(ctx, p, "wordpressv2-interface-missing") +} + +func publishBundleDir(ctx context.Context, p *porter.TestPorter, bundleDirName string) { + bunDir, err := os.MkdirTemp("", "porter-"+bundleDirName+"-") + require.NoError(p.T(), err) + defer os.RemoveAll(bunDir) + + p.TestConfig.TestContext.AddTestDirectory(filepath.Join(p.RepoRoot, "build/testdata/bundles", bundleDirName), bunDir) + + pwd := p.Getwd() + p.Chdir(bunDir) + defer p.Chdir(pwd) + + publishOpts := porter.PublishOptions{} + publishOpts.Force = true + err = publishOpts.Validate(p.Config) + require.NoError(p.T(), err) + + err = p.Publish(ctx, publishOpts) + require.NoError(p.T(), err) +}