[8.x] [Http] Gracefully onboarding internal routes to `VersionedRouter` (#194789) (#194902)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Http] Gracefully onboarding internal routes to
`VersionedRouter`
(#194789)](https://github.com/elastic/kibana/pull/194789)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Jean-Louis
Leysens","email":"jeanlouis.leysens@elastic.co"},"sourceCommit":{"committedDate":"2024-10-04T06:47:36Z","message":"[Http]
Gracefully onboarding internal routes to `VersionedRouter`
(#194789)\n\n## Summary\r\n\r\nIf an internal route goes from
unversioned -> versioned today this will\r\nsurface as a breaking
change. This PR allows internal routes to\r\ngracefully onboard to the
versioned router.\r\n\r\nThe fix is to default to version `1` of an
internal route if no version\r\nis provided. In this way old clients can
continue to interact with the\r\nnew servers as developers onboard to
the versioned router and roll out\r\nv2+ of an internal route.\r\n\r\n##
Notes\r\n\r\nWe could use `buildNr` to narrow down internal routes
defaulting to v1\r\nto older clients only. IMO this would be more
accurate, but also of\r\nmarginal value. My rationale is: by requiring
explicit versions during\r\ndev time the risk of us shipping new builds
that don't provide a version\r\nis effectively nullified. Additionally
we already run this risk with our\r\npublic route default behaviour (we
even require that public routes have\r\nexplicit version in dev to
mitigate this same risk for existing public\r\nclients).\r\n\r\nIf
reviewers feel otherwise I'm happy to revisit this!\r\n\r\n###
Checklist\r\n\r\nDelete any items that are not applicable to this
PR.\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n\r\n\r\n### Risk
Matrix\r\n\r\n| Risk | Probability | Severity | Mitigation/Notes
|\r\n\r\n|---------------------------|-------------|----------|-------------------------|\r\n|
By defaulting internal routes to v1 it's possible that
behaviour\r\nbecomes less predictable. | Low | Low | This is already
true for public\r\nroutes and we are allowing a more limited/more
predictable version of\r\nthat here.
|","sha":"afecfd8889b042501d8de72b6eb6d8a1b98cb586","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Feature:http","Team:Core","release_note:skip","v9.0.0","v8.16.0","backport:version"],"title":"[Http]
Gracefully onboarding internal routes to
`VersionedRouter`","number":194789,"url":"https://github.com/elastic/kibana/pull/194789","mergeCommit":{"message":"[Http]
Gracefully onboarding internal routes to `VersionedRouter`
(#194789)\n\n## Summary\r\n\r\nIf an internal route goes from
unversioned -> versioned today this will\r\nsurface as a breaking
change. This PR allows internal routes to\r\ngracefully onboard to the
versioned router.\r\n\r\nThe fix is to default to version `1` of an
internal route if no version\r\nis provided. In this way old clients can
continue to interact with the\r\nnew servers as developers onboard to
the versioned router and roll out\r\nv2+ of an internal route.\r\n\r\n##
Notes\r\n\r\nWe could use `buildNr` to narrow down internal routes
defaulting to v1\r\nto older clients only. IMO this would be more
accurate, but also of\r\nmarginal value. My rationale is: by requiring
explicit versions during\r\ndev time the risk of us shipping new builds
that don't provide a version\r\nis effectively nullified. Additionally
we already run this risk with our\r\npublic route default behaviour (we
even require that public routes have\r\nexplicit version in dev to
mitigate this same risk for existing public\r\nclients).\r\n\r\nIf
reviewers feel otherwise I'm happy to revisit this!\r\n\r\n###
Checklist\r\n\r\nDelete any items that are not applicable to this
PR.\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n\r\n\r\n### Risk
Matrix\r\n\r\n| Risk | Probability | Severity | Mitigation/Notes
|\r\n\r\n|---------------------------|-------------|----------|-------------------------|\r\n|
By defaulting internal routes to v1 it's possible that
behaviour\r\nbecomes less predictable. | Low | Low | This is already
true for public\r\nroutes and we are allowing a more limited/more
predictable version of\r\nthat here.
|","sha":"afecfd8889b042501d8de72b6eb6d8a1b98cb586"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/194789","number":194789,"mergeCommit":{"message":"[Http]
Gracefully onboarding internal routes to `VersionedRouter`
(#194789)\n\n## Summary\r\n\r\nIf an internal route goes from
unversioned -> versioned today this will\r\nsurface as a breaking
change. This PR allows internal routes to\r\ngracefully onboard to the
versioned router.\r\n\r\nThe fix is to default to version `1` of an
internal route if no version\r\nis provided. In this way old clients can
continue to interact with the\r\nnew servers as developers onboard to
the versioned router and roll out\r\nv2+ of an internal route.\r\n\r\n##
Notes\r\n\r\nWe could use `buildNr` to narrow down internal routes
defaulting to v1\r\nto older clients only. IMO this would be more
accurate, but also of\r\nmarginal value. My rationale is: by requiring
explicit versions during\r\ndev time the risk of us shipping new builds
that don't provide a version\r\nis effectively nullified. Additionally
we already run this risk with our\r\npublic route default behaviour (we
even require that public routes have\r\nexplicit version in dev to
mitigate this same risk for existing public\r\nclients).\r\n\r\nIf
reviewers feel otherwise I'm happy to revisit this!\r\n\r\n###
Checklist\r\n\r\nDelete any items that are not applicable to this
PR.\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n\r\n\r\n### Risk
Matrix\r\n\r\n| Risk | Probability | Severity | Mitigation/Notes
|\r\n\r\n|---------------------------|-------------|----------|-------------------------|\r\n|
By defaulting internal routes to v1 it's possible that
behaviour\r\nbecomes less predictable. | Low | Low | This is already
true for public\r\nroutes and we are allowing a more limited/more
predictable version of\r\nthat here.
|","sha":"afecfd8889b042501d8de72b6eb6d8a1b98cb586"}},{"branch":"8.x","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Jean-Louis Leysens <jeanlouis.leysens@elastic.co>
This commit is contained in:
Kibana Machine 2024-10-04 18:34:06 +10:00 committed by GitHub
parent 18b2b5c371
commit bc434697c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 84 additions and 31 deletions

View file

@ -548,7 +548,7 @@ describe('Versioned route', () => {
expect(
// @ts-expect-error
versionedRoute.getSecurity({
headers: {},
headers: { [ELASTIC_HTTP_VERSION_HEADER]: '99' },
})
).toStrictEqual(securityConfigDefault);
@ -569,7 +569,7 @@ describe('Versioned route', () => {
expect(
// @ts-expect-error
versionedRoute.getSecurity({
headers: {},
headers: { [ELASTIC_HTTP_VERSION_HEADER]: '99' },
})
).toStrictEqual(securityConfigDefault);
expect(router.get).toHaveBeenCalledTimes(1);

View file

@ -25,10 +25,10 @@ import type {
RouteConfigOptions,
RouteSecurityGetter,
RouteSecurity,
RouteMethod,
} from '@kbn/core-http-server';
import type { Mutable } from 'utility-types';
import type { Method, VersionedRouterRoute } from './types';
import type { CoreVersionedRouter } from './core_versioned_router';
import type { HandlerResolutionStrategy, Method, VersionedRouterRoute } from './types';
import { validate } from './validate';
import {
@ -44,9 +44,16 @@ import { validRouteSecurity } from '../security_route_config_validator';
import { resolvers } from './handler_resolvers';
import { prepareVersionedRouteValidation, unwrapVersionedResponseBodyValidation } from './util';
import type { RequestLike } from './route_version_utils';
import { Router } from '../router';
type Options = AddVersionOpts<unknown, unknown, unknown>;
interface InternalVersionedRouteConfig<M extends RouteMethod> extends VersionedRouteConfig<M> {
isDev: boolean;
useVersionResolutionStrategyForInternalPaths: Map<string, boolean>;
defaultHandlerResolutionStrategy: HandlerResolutionStrategy;
}
// This validation is a pass-through so that we can apply our version-specific validation later
export const passThroughValidation = {
body: schema.nullable(schema.any()),
@ -75,29 +82,43 @@ export class CoreVersionedRoute implements VersionedRoute {
path,
options,
}: {
router: CoreVersionedRouter;
router: Router;
method: Method;
path: string;
options: VersionedRouteConfig<Method>;
options: InternalVersionedRouteConfig<Method>;
}) {
return new CoreVersionedRoute(router, method, path, options);
}
public readonly options: VersionedRouteConfig<Method>;
private useDefaultStrategyForPath: boolean;
private isPublic: boolean;
private isDev: boolean;
private enableQueryVersion: boolean;
private defaultSecurityConfig: RouteSecurity | undefined;
private defaultHandlerResolutionStrategy: HandlerResolutionStrategy;
private constructor(
private readonly router: CoreVersionedRouter,
private readonly router: Router,
public readonly method: Method,
public readonly path: string,
public readonly options: VersionedRouteConfig<Method>
internalOptions: InternalVersionedRouteConfig<Method>
) {
this.useDefaultStrategyForPath = router.useVersionResolutionStrategyForInternalPaths.has(path);
this.isPublic = this.options.access === 'public';
this.enableQueryVersion = this.options.enableQueryVersion === true;
this.defaultSecurityConfig = validRouteSecurity(this.options.security, this.options.options);
this.router.router[this.method](
const {
isDev,
useVersionResolutionStrategyForInternalPaths,
defaultHandlerResolutionStrategy,
...options
} = internalOptions;
this.isPublic = options.access === 'public';
this.isDev = isDev;
this.defaultHandlerResolutionStrategy = defaultHandlerResolutionStrategy;
this.useDefaultStrategyForPath =
this.isPublic || useVersionResolutionStrategyForInternalPaths.has(path);
this.enableQueryVersion = options.enableQueryVersion === true;
this.defaultSecurityConfig = validRouteSecurity(options.security, options.options);
this.options = options;
this.router[this.method](
{
path: this.path,
validate: passThroughValidation,
@ -119,7 +140,7 @@ export class CoreVersionedRoute implements VersionedRoute {
/** This method assumes that one or more versions handlers are registered */
private getDefaultVersion(): undefined | ApiVersion {
return resolvers[this.router.defaultHandlerResolutionStrategy](
return resolvers[this.defaultHandlerResolutionStrategy](
[...this.handlers.keys()],
this.options.access
);
@ -132,8 +153,14 @@ export class CoreVersionedRoute implements VersionedRoute {
private getVersion(req: RequestLike): ApiVersion | undefined {
let version;
const maybeVersion = readVersion(req, this.enableQueryVersion);
if (!maybeVersion && (this.isPublic || this.useDefaultStrategyForPath)) {
version = this.getDefaultVersion();
if (!maybeVersion) {
if (this.useDefaultStrategyForPath) {
version = this.getDefaultVersion();
} else if (!this.isDev && !this.isPublic) {
// When in production, we default internal routes to v1 to allow
// gracefully onboarding of un-versioned to versioned routes
version = '1';
}
} else {
version = maybeVersion;
}
@ -207,7 +234,7 @@ export class CoreVersionedRoute implements VersionedRoute {
const response = await handler.fn(ctx, req, res);
if (this.router.isDev && validation?.response?.[response.status]?.body) {
if (this.isDev && validation?.response?.[response.status]?.body) {
const { [response.status]: responseValidation, unsafe } = validation.response;
try {
validate(
@ -236,7 +263,7 @@ export class CoreVersionedRoute implements VersionedRoute {
private validateVersion(version: string) {
// We do an additional check here while we only have a single allowed public version
// for all public Kibana HTTP APIs
if (this.router.isDev && this.isPublic) {
if (this.isDev && this.isPublic) {
const message = isAllowedPublicVersion(version);
if (message) {
throw new Error(message);

View file

@ -74,10 +74,16 @@ export class CoreVersionedRouter implements VersionedRouter {
(routeMethod: Method) =>
(options: VersionedRouteConfig<Method>): VersionedRoute<Method, any> => {
const route = CoreVersionedRoute.from({
router: this,
router: this.router,
method: routeMethod,
path: options.path,
options,
options: {
...options,
defaultHandlerResolutionStrategy: this.defaultHandlerResolutionStrategy,
useVersionResolutionStrategyForInternalPaths:
this.useVersionResolutionStrategyForInternalPaths,
isDev: this.isDev,
},
});
this.routes.add(route);
return route;

View file

@ -293,31 +293,31 @@ describe('Routing versioned requests', () => {
).resolves.toEqual('1');
});
it('requires version headers to be set for internal HTTP APIs', async () => {
it('defaults to v1 for internal HTTP APIs to allow gracefully onboarding internal routes to versioned router', async () => {
await setupServer({ dev: false });
router.versioned
.get({ path: '/my-path', access: 'internal' })
.get({ path: '/my-internal-path', access: 'internal' })
.addVersion(
{ version: '1', validate: { response: { 200: { body: () => schema.number() } } } },
async (ctx, req, res) => res.ok()
async (ctx, req, res) => res.ok({ body: 'v1' })
)
.addVersion(
{ version: '2', validate: { response: { 200: { body: () => schema.number() } } } },
async (ctx, req, res) => res.ok()
async (ctx, req, res) => res.ok({ body: 'v2' })
);
await server.start();
await expect(
supertest
.get('/my-path')
.get('/my-internal-path')
.unset('Elastic-Api-Version')
.expect(400)
.then(({ body }) => body.message)
).resolves.toMatch(/Please specify.+version/);
.expect(200)
.then(({ text }) => text)
).resolves.toMatch('v1');
});
it('requires version headers to be set for public endpoints when in dev', async () => {
it('requires version headers to be set for internal and public endpoints when in dev', async () => {
await setupServer({ dev: true });
router.versioned
.get({
@ -325,6 +325,14 @@ describe('Routing versioned requests', () => {
access: 'public',
})
.addVersion({ version: '2023-10-31', validate: false }, async (ctx, req, res) => res.ok());
router.versioned
.get({
path: '/my-internal-path',
access: 'internal',
})
.addVersion({ version: '1', validate: false }, async (ctx, req, res) => res.ok());
await server.start();
await expect(
@ -334,6 +342,18 @@ describe('Routing versioned requests', () => {
.expect(400)
.then(({ body }) => body.message)
).resolves.toMatch(/Please specify.+version/);
await supertest.get('/my-path').set('Elastic-Api-Version', '2023-10-31').expect(200);
await expect(
supertest
.get('/my-internal-path')
.unset('Elastic-Api-Version')
.expect(400)
.then(({ body }) => body.message)
).resolves.toMatch(/Please specify.+version/);
await supertest.get('/my-internal-path').set('Elastic-Api-Version', '1').expect(200);
});
it('errors when no handler could be found', async () => {

View file

@ -122,13 +122,13 @@ export default function aggregateTests({ getService }: FtrProviderContext) {
});
it(`${AGGREGATE_ROUTE} handles a bad request`, async () => {
const response = await supertest.get(AGGREGATE_ROUTE).set('kbn-xsrf', 'foo').query({
const response = await getRoute().query({
query: 'asdf',
groupBy: ORCHESTRATOR_NAMESPACE,
page: 0,
index: MOCK_INDEX,
});
expect(response.status).to.be(400);
expect(response.status).to.be(500);
});
});
}