Skip to content

Commit

Permalink
[7.x] [dev-utils] implement basic KbnClient util for talking t… (elas…
Browse files Browse the repository at this point in the history
…tic#47003)

* [dev-utils] implement basic KbnClient util for talking to Kibana server

* update KbnClient to expose full KibanaServerService API

* expose request() function and uriencode helper

* [uiSettings] retry read on conflicts auto upgrading

* expose function for resolving a Kibana server url

* only use apis in test hooks

* run x-pack-ciGroup2 60 times

* log retries as errors so they are included in console output for job

* bump

* Revert "run x-pack-ciGroup2 60 times"

This reverts commit 6b6f392.

* refactor urlencode tag to be a little clearer

* support customizing maxAttempts in request method
  • Loading branch information
Spencer authored Oct 1, 2019
1 parent 1e2f6d8 commit 813dbbd
Show file tree
Hide file tree
Showing 22 changed files with 528 additions and 267 deletions.
1 change: 1 addition & 0 deletions packages/kbn-dev-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ export { createAbsolutePathSerializer } from './serializers';
export { CA_CERT_PATH, ES_KEY_PATH, ES_CERT_PATH } from './certs';
export { run, createFailError, createFlagError, combineErrors, isFailError, Flags } from './run';
export { REPO_ROOT } from './constants';
export { KbnClient } from './kbn_client';
42 changes: 42 additions & 0 deletions packages/kbn-dev-utils/src/kbn_client/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { AxiosError, AxiosResponse } from 'axios';

export interface AxiosRequestError extends AxiosError {
response: undefined;
}

export interface AxiosResponseError<T> extends AxiosError {
response: AxiosResponse<T>;
}

export const isAxiosRequestError = (error: any): error is AxiosRequestError => {
return error && error.code === undefined && error.response === undefined;
};

export const isAxiosResponseError = (error: any): error is AxiosResponseError<any> => {
return error && error.code !== undefined && error.response !== undefined;
};

export const isConcliftOnGetError = (error: any) => {
return (
isAxiosResponseError(error) && error.config.method === 'GET' && error.response.status === 409
);
};
21 changes: 21 additions & 0 deletions packages/kbn-dev-utils/src/kbn_client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

export { KbnClient } from './kbn_client';
export { uriencode } from './kbn_client_requester';
64 changes: 64 additions & 0 deletions packages/kbn-dev-utils/src/kbn_client/kbn_client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { ToolingLog } from '../tooling_log';
import { KbnClientRequester, ReqOptions } from './kbn_client_requester';
import { KbnClientStatus } from './kbn_client_status';
import { KbnClientPlugins } from './kbn_client_plugins';
import { KbnClientVersion } from './kbn_client_version';
import { KbnClientSavedObjects } from './kbn_client_saved_objects';
import { KbnClientUiSettings, UiSettingValues } from './kbn_client_ui_settings';

export class KbnClient {
private readonly requester = new KbnClientRequester(this.log, this.kibanaUrls);
readonly status = new KbnClientStatus(this.requester);
readonly plugins = new KbnClientPlugins(this.status);
readonly version = new KbnClientVersion(this.status);
readonly savedObjects = new KbnClientSavedObjects(this.log, this.requester);
readonly uiSettings = new KbnClientUiSettings(this.log, this.requester, this.uiSettingDefaults);

/**
* Basic Kibana server client that implements common behaviors for talking
* to the Kibana server from dev tooling.
*
* @param log ToolingLog
* @param kibanaUrls Array of kibana server urls to send requests to
* @param uiSettingDefaults Map of uiSetting values that will be merged with all uiSetting resets
*/
constructor(
private readonly log: ToolingLog,
private readonly kibanaUrls: string[],
private readonly uiSettingDefaults?: UiSettingValues
) {
if (!kibanaUrls.length) {
throw new Error('missing Kibana urls');
}
}

/**
* Make a direct request to the Kibana server
*/
async request(options: ReqOptions) {
return await this.requester.request(options);
}

resolveUrl(relativeUrl: string) {
return this.requester.resolveUrl(relativeUrl);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,28 @@
* under the License.
*/

export class KibanaServerVersion {
constructor(kibanaStatus) {
this.kibanaStatus = kibanaStatus;
this._cachedVersionNumber;
}
import { KbnClientStatus } from './kbn_client_status';

async get() {
if (this._cachedVersionNumber) {
return this._cachedVersionNumber;
}
const PLUGIN_STATUS_ID = /^plugin:(.+?)@/;

export class KbnClientPlugins {
constructor(private readonly status: KbnClientStatus) {}
/**
* Get a list of plugin ids that are enabled on the server
*/
public async getEnabledIds() {
const pluginIds: string[] = [];
const apiResp = await this.status.get();

const status = await this.kibanaStatus.get();
if (status && status.version && status.version.number) {
this._cachedVersionNumber = status.version.number + (status.version.build_snapshot ? '-SNAPSHOT' : '');
return this._cachedVersionNumber;
for (const status of apiResp.status.statuses) {
if (status.id) {
const match = status.id.match(PLUGIN_STATUS_ID);
if (match) {
pluginIds.push(match[1]);
}
}
}

throw new Error(`Unable to fetch Kibana Server status, received ${JSON.stringify(status)}`);
return pluginIds;
}
}
124 changes: 124 additions & 0 deletions packages/kbn-dev-utils/src/kbn_client/kbn_client_requester.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import Url from 'url';

import Axios from 'axios';

import { isAxiosRequestError, isConcliftOnGetError } from './errors';
import { ToolingLog } from '../tooling_log';

export const uriencode = (
strings: TemplateStringsArray,
...values: Array<string | number | boolean>
) => {
const queue = strings.slice();

if (queue.length === 0) {
throw new Error('how could strings passed to `uriencode` template tag be empty?');
}

if (queue.length !== values.length + 1) {
throw new Error('strings and values passed to `uriencode` template tag are unbalanced');
}

// pull the first string off the queue, there is one less item in `values`
// since the values are always wrapped in strings, so we shift the extra string
// off the queue to balance the queue and values array.
const leadingString = queue.shift()!;
return queue.reduce(
(acc, string, i) => `${acc}${encodeURIComponent(values[i])}${string}`,
leadingString
);
};

const DEFAULT_MAX_ATTEMPTS = 5;

export interface ReqOptions {
description?: string;
path: string;
query?: Record<string, any>;
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: any;
attempt?: number;
maxAttempts?: number;
}

const delay = (ms: number) =>
new Promise(resolve => {
setTimeout(resolve, ms);
});

export class KbnClientRequester {
constructor(private readonly log: ToolingLog, private readonly kibanaUrls: string[]) {}

private pickUrl() {
const url = this.kibanaUrls.shift()!;
this.kibanaUrls.push(url);
return url;
}

public resolveUrl(relativeUrl: string = '/') {
return Url.resolve(this.pickUrl(), relativeUrl);
}

async request<T>(options: ReqOptions): Promise<T> {
const url = Url.resolve(this.pickUrl(), options.path);
const description = options.description || `${options.method} ${url}`;
const attempt = options.attempt === undefined ? 1 : options.attempt;
const maxAttempts =
options.maxAttempts === undefined ? DEFAULT_MAX_ATTEMPTS : options.maxAttempts;

try {
const response = await Axios.request<T>({
method: options.method,
url,
data: options.body,
params: options.query,
headers: {
'kbn-xsrf': 'kbn-client',
},
});

return response.data;
} catch (error) {
let retryErrorMsg: string | undefined;
if (isAxiosRequestError(error)) {
retryErrorMsg = `[${description}] request failed (attempt=${attempt})`;
} else if (isConcliftOnGetError(error)) {
retryErrorMsg = `Conflict on GET (path=${options.path}, attempt=${attempt})`;
}

if (retryErrorMsg) {
if (attempt < maxAttempts) {
this.log.error(retryErrorMsg);
await delay(1000 * attempt);
return await this.request<T>({
...options,
attempt: attempt + 1,
});
}

throw new Error(retryErrorMsg + ' and ran out of retries');
}

throw error;
}
}
}
Loading

0 comments on commit 813dbbd

Please sign in to comment.