Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/0034-v2-disabled-capability-probe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[BugFixes]
- The CF capability probe no longer reports v2 support on foundations where the CAPI v2 API is disabled. Such foundations still answer `/v2/info` with 200 and a blank `api_version`, which the probe previously read as v2-enabled — leaving `V2Info.APIVersion` empty and breaking `cf push` through the deploy wizard. The probe now requires a populated `api_version`, while still keeping the served `/v2/info` body for the SSH fields the root document does not carry, so application SSH keeps working (#5727).
4 changes: 4 additions & 0 deletions src/jetstream/mock_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,10 @@ var mockV2InfoResponse = api.V2Info{
AuthorizationEndpoint: mockAuthEndpoint,
TokenEndpoint: mockTokenEndpoint,
DopplerLoggingEndpoint: mockDopplerEndpoint,
// A v2-enabled foundation always serves api_version; a blank one is the
// v2-disabled marker (cloud_controller_ng#4280) and would make the CF
// capability probe classify this mock as v2-disabled.
APIVersion: "2.164.0",
}

var mockApiRootResponse = api.ApiRoot{
Expand Down
247 changes: 247 additions & 0 deletions src/jetstream/plugins/cloudfoundry/capability_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
package cloudfoundry

import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/cloudfoundry/stratos/src/jetstream/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// capabilityProbeProxy satisfies api.PortalProxy for Info() /
// confirmCapabilityMetadata tests by embedding the interface — only the two
// methods those paths call are implemented; anything else panics, which is
// the desired failure mode for a probe test reaching further than expected.
type capabilityProbeProxy struct {
api.PortalProxy
updatedMetadata map[string]string
}

func (p *capabilityProbeProxy) GetHttpClient(_ bool, _ string) http.Client {
return *http.DefaultClient
}

func (p *capabilityProbeProxy) UpdateEndpointMetadata(guid string, metadata string) error {
if p.updatedMetadata == nil {
p.updatedMetadata = map[string]string{}
}
p.updatedMetadata[guid] = metadata
return nil
}

// v2-disabled foundation, per cloud_controller_ng#4280: /v2/info stays up and
// returns 200 with every endpoint field populated; only api_version is blanked
// (plus support carrying the disabled marker). The endpoint values deliberately
// differ from the root-link values below so assertions can tell which source
// each field came from.
const v2DisabledInfoJSON = `{
"name": "",
"support": "CF API v2 is disabled",
"authorization_endpoint": "https://v2-login.example.com",
"token_endpoint": "https://v2-uaa.example.com",
"min_cli_version": "6.23.0",
"app_ssh_endpoint": "ssh.example.com:2222",
"app_ssh_host_key_fingerprint": "aa:bb:cc:dd",
"app_ssh_oauth_client": "ssh-proxy",
"doppler_logging_endpoint": "wss://v2-doppler.example.com:443",
"routing_endpoint": "https://v2-api.example.com/routing",
"api_version": ""
}`

const v2EnabledInfoJSON = `{
"authorization_endpoint": "https://v2-login.example.com",
"token_endpoint": "https://v2-uaa.example.com",
"min_cli_version": "6.23.0",
"app_ssh_endpoint": "ssh.example.com:2222",
"app_ssh_host_key_fingerprint": "aa:bb:cc:dd",
"app_ssh_oauth_client": "ssh-proxy",
"doppler_logging_endpoint": "wss://v2-doppler.example.com:443",
"routing_endpoint": "https://v2-api.example.com/routing",
"api_version": "2.264.0"
}`

const rootLinksJSON = `{
"links": {
"cloud_controller_v3": {"href": "https://api.example.com/v3", "meta": {"version": "3.195.0"}},
"login": {"href": "https://root-login.example.com"},
"uaa": {"href": "https://root-uaa.example.com"},
"logging": {"href": "wss://root-doppler.example.com:443"},
"routing": {"href": "https://root-api.example.com/routing"},
"log_cache": {"href": "https://log-cache.example.com"}
}
}`

const v3InfoJSON = `{
"links": {"self": {"href": "https://api.example.com/v3/info"}}
}`

// newCFServer serves a fake CF API: the root document, /v3/info, and whatever
// /v2/info body the test supplies (empty string → 404, the pre-#4280 shape).
func newCFServer(t *testing.T, v2InfoBody string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/":
_, _ = w.Write([]byte(rootLinksJSON))
case "/v2/info":
if v2InfoBody == "" {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte(v2InfoBody))
case "/v3/info":
_, _ = w.Write([]byte(v3InfoJSON))
default:
http.NotFound(w, r)
}
}))
}

func decodeMetadata(t *testing.T, raw string) api.CFEndpointMetadata {
t.Helper()
var m api.CFEndpointMetadata
require.NoError(t, json.Unmarshal([]byte(raw), &m))
return m
}

// On a v2-disabled foundation /v2/info answers 200 with a populated
// authorization_endpoint and api_version:"" — the probe must read that as
// v2 OFF, while still keeping the served body for the fields (app_ssh_*,
// min_cli_version) that the root document cannot supply.
func TestInfo_V2DisabledFoundation(t *testing.T) {
ts := newCFServer(t, v2DisabledInfoJSON)
defer ts.Close()

c := &CloudFoundrySpecification{portalProxy: &capabilityProbeProxy{}, endpointType: "cf"}
cnsi, rawInfo, err := c.Info(ts.URL, true, "")
require.NoError(t, err)
endpointInfo, ok := rawInfo.(api.EndpointInfo)
require.True(t, ok)

meta := decodeMetadata(t, cnsi.Metadata)
assert.False(t, meta.SupportsV2, "v2-disabled foundation must not register as SupportsV2")
assert.True(t, meta.SupportsV3)
assert.False(t, meta.Assumed)

// backfillFromRoot must have run: version + endpoints come from root links
assert.Equal(t, "3.195.0", endpointInfo.V2Info.APIVersion)
assert.Equal(t, "https://root-login.example.com", cnsi.AuthorizationEndpoint)
assert.Equal(t, "https://root-uaa.example.com", cnsi.TokenEndpoint)
assert.Equal(t, "wss://root-doppler.example.com:443", cnsi.DopplerLoggingEndpoint)
assert.Equal(t, "https://root-login.example.com", endpointInfo.V2Info.AuthorizationEndpoint)
assert.Equal(t, "https://root-uaa.example.com", endpointInfo.V2Info.TokenEndpoint)
assert.Equal(t, "https://root-api.example.com/routing", endpointInfo.V2Info.RoutingEndpoint)

// ...while the fields only /v2/info carries survive from the served body
assert.Equal(t, "ssh.example.com:2222", endpointInfo.V2Info.AppSSHEndpoint)
assert.Equal(t, "ssh-proxy", endpointInfo.V2Info.AppSSHOauthCLient)
assert.Equal(t, "aa:bb:cc:dd", endpointInfo.V2Info.AppSSHHostKeyFingerprint)
assert.Equal(t, "6.23.0", endpointInfo.V2Info.MinCLIVersion)
}

// A v2-enabled foundation (api_version populated) keeps today's behaviour:
// SupportsV2, and /v2/info stays the source of truth for every field.
func TestInfo_V2EnabledFoundation(t *testing.T) {
ts := newCFServer(t, v2EnabledInfoJSON)
defer ts.Close()

c := &CloudFoundrySpecification{portalProxy: &capabilityProbeProxy{}, endpointType: "cf"}
cnsi, rawInfo, err := c.Info(ts.URL, true, "")
require.NoError(t, err)
endpointInfo, ok := rawInfo.(api.EndpointInfo)
require.True(t, ok)

meta := decodeMetadata(t, cnsi.Metadata)
assert.True(t, meta.SupportsV2)
assert.True(t, meta.SupportsV3)
assert.False(t, meta.Assumed)

assert.Equal(t, "2.264.0", endpointInfo.V2Info.APIVersion)
assert.Equal(t, "https://v2-login.example.com", cnsi.AuthorizationEndpoint)
assert.Equal(t, "https://v2-uaa.example.com", cnsi.TokenEndpoint)
assert.Equal(t, "wss://v2-doppler.example.com:443", cnsi.DopplerLoggingEndpoint)
assert.Equal(t, "https://v2-login.example.com", endpointInfo.V2Info.AuthorizationEndpoint)
assert.Equal(t, "https://v2-api.example.com/routing", endpointInfo.V2Info.RoutingEndpoint)
}

// Pre-#4280 shape: /v2/info genuinely 404s. Same observable outcome as
// v2-disabled except the /v2/info-only fields are unavailable.
func TestInfo_V2InfoAbsent(t *testing.T) {
ts := newCFServer(t, "")
defer ts.Close()

c := &CloudFoundrySpecification{portalProxy: &capabilityProbeProxy{}, endpointType: "cf"}
cnsi, rawInfo, err := c.Info(ts.URL, true, "")
require.NoError(t, err)
endpointInfo, ok := rawInfo.(api.EndpointInfo)
require.True(t, ok)

meta := decodeMetadata(t, cnsi.Metadata)
assert.False(t, meta.SupportsV2)
assert.True(t, meta.SupportsV3)

assert.Equal(t, "3.195.0", endpointInfo.V2Info.APIVersion)
assert.Equal(t, "https://root-login.example.com", cnsi.AuthorizationEndpoint)
assert.Empty(t, endpointInfo.V2Info.AppSSHEndpoint)
}

// When the root document answers but both info probes fail, Info() assumes v2
// with Assumed=true. An assumed v2 is not a proven v2: the backfill must still
// run so the CNSI record registers with the root-link endpoints instead of
// nothing, and the connect-time re-probe can correct the flags later.
func TestInfo_ProbesInconclusiveBackfillsFromRoot(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(rootLinksJSON))
return
}
http.NotFound(w, r)
}))
defer ts.Close()

c := &CloudFoundrySpecification{portalProxy: &capabilityProbeProxy{}, endpointType: "cf"}
cnsi, rawInfo, err := c.Info(ts.URL, true, "")
require.NoError(t, err)
endpointInfo, ok := rawInfo.(api.EndpointInfo)
require.True(t, ok)

meta := decodeMetadata(t, cnsi.Metadata)
assert.True(t, meta.Assumed)
assert.True(t, meta.SupportsV2)

assert.Equal(t, "https://root-login.example.com", cnsi.AuthorizationEndpoint)
assert.Equal(t, "https://root-uaa.example.com", cnsi.TokenEndpoint)
assert.Equal(t, "3.195.0", endpointInfo.V2Info.APIVersion)
}

// confirmCapabilityMetadata re-probes independently of Info() and must reach
// the same conclusion on a v2-disabled foundation.
func TestConfirmCapabilityMetadata_V2DisabledFoundation(t *testing.T) {
ts := newCFServer(t, v2DisabledInfoJSON)
defer ts.Close()

assumed, err := json.Marshal(api.CFEndpointMetadata{SupportsV2: true, Assumed: true})
require.NoError(t, err)
apiURL, err := url.Parse(ts.URL)
require.NoError(t, err)

proxy := &capabilityProbeProxy{}
c := &CloudFoundrySpecification{portalProxy: proxy, endpointType: "cf"}
c.confirmCapabilityMetadata(api.CNSIRecord{
GUID: "cnsi-guid",
APIEndpoint: apiURL,
Metadata: string(assumed),
})

require.Contains(t, proxy.updatedMetadata, "cnsi-guid")
meta := decodeMetadata(t, proxy.updatedMetadata["cnsi-guid"])
assert.False(t, meta.SupportsV2, "re-probe must not confirm SupportsV2 on a v2-disabled foundation")
assert.True(t, meta.SupportsV3)
assert.False(t, meta.Assumed)
}
14 changes: 8 additions & 6 deletions src/jetstream/plugins/cloudfoundry/endpoint_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ type rootEndpoints struct {

// deriveEndpointsFromRoot extracts the auth/uaa/logging/routing endpoints and
// the CC API version from the CF root `/` links. It is the fallback source used
// when /v2/info is absent (V2 API disabled), so endpoint registration and
// cf push remain functional on a v3-only foundation.
// when /v2/info is absent or the V2 API is disabled, so endpoint registration
// and cf push remain functional on a v3-only foundation.
func deriveEndpointsFromRoot(root api.ApiRoot) rootEndpoints {
return rootEndpoints{
AuthorizationEndpoint: root.Links.Login.Href,
Expand All @@ -27,10 +27,12 @@ func deriveEndpointsFromRoot(root api.ApiRoot) rootEndpoints {
}

// backfillFromRoot populates the CNSI record's auth/token/doppler endpoints and
// the V2Info struct from the root `/` links when /v2/info did not supply them
// (V2 API disabled). When /v2/info responded (supportsV2), it remains the source
// of truth and this is a no-op so v2-enabled foundations keep their existing
// behavior.
// the V2Info struct from the root `/` links when the V2 API is disabled or
// /v2/info is absent. When /v2/info proved v2 enabled (supportsV2), it remains
// the source of truth and this is a no-op so v2-enabled foundations keep their
// existing behavior. Fields root cannot supply (app_ssh_*, min_cli_version)
// are left as /v2/info served them — a v2-disabled foundation still populates
// those, and cfappssh depends on them.
func backfillFromRoot(newCNSI *api.CNSIRecord, v2Info *api.V2Info, root api.ApiRoot, supportsV2 bool) {
if supportsV2 {
return
Expand Down
16 changes: 10 additions & 6 deletions src/jetstream/plugins/cloudfoundry/endpoint_info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@ func TestDeriveEndpointsFromRoot_EmptyLinks(t *testing.T) {
assert.Empty(t, ep.APIVersion)
}

// On a v3-only CF (/v2/info absent → supportsV2 false), backfillFromRoot fills
// the CNSI record's token/auth endpoints (needed for token refresh) and the
// V2Info struct (consumed by cfapppush) from the root links.
// On a v3-only CF (V2 API disabled — /v2/info still answers 200 but with
// api_version:"" — or genuinely absent; either way supportsV2 false),
// backfillFromRoot fills the CNSI record's token/auth endpoints (needed for
// token refresh) and the V2Info struct (consumed by cfapppush) from the root
// links.
func TestBackfillFromRoot_V3OnlyPopulatesCNSIAndV2Info(t *testing.T) {
var root api.ApiRoot
require.NoError(t, json.Unmarshal([]byte(`{"links":{
Expand All @@ -74,9 +76,11 @@ func TestBackfillFromRoot_V3OnlyPopulatesCNSIAndV2Info(t *testing.T) {
assert.Equal(t, "3.180.0", v2.APIVersion)
}

// When /v2/info responded (supportsV2 true), it stays the source of truth —
// backfillFromRoot must not clobber the V2-supplied endpoints with root links.
func TestBackfillFromRoot_V2PresentDoesNotOverwrite(t *testing.T) {
// When /v2/info proved the V2 API enabled (supportsV2 true — a populated
// api_version, not merely a 200; a v2-disabled foundation also answers 200),
// it stays the source of truth — backfillFromRoot must not clobber the
// V2-supplied endpoints with root links.
func TestBackfillFromRoot_V2EnabledDoesNotOverwrite(t *testing.T) {
var root api.ApiRoot
require.NoError(t, json.Unmarshal([]byte(`{"links":{"login":{"href":"https://root-login.example.com"}}}`), &root))

Expand Down
Loading
Loading