Merge branch 'master' into feat/dynatrace-plugin-synthetics

This commit is contained in:
Isaiah Thiessen
2022-06-27 15:44:30 -07:00
38 changed files with 1483 additions and 307 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Change BackstageIconLinkVertical style to use pallette instead of explicit color
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Improve error messaging when passing in malformed auth
+24
View File
@@ -0,0 +1,24 @@
---
'@backstage/plugin-search-backend-module-elasticsearch': minor
---
**BREAKING**: In order to remain interoperable with all currently supported
deployments of Elasticsearch, this package will now conditionally use either
the `@elastic/elasticsearch` or `@opensearch-project/opensearch` client when
communicating with your deployed cluster.
If you do not rely on types exported from this package, or if you do not make
use of the `createElasticSearchClientOptions` or `newClient` methods on the
`ElasticSearchSearchEngine` class, then upgrading to this version should not
require any further action on your part. Everything will continue to work as it
always has.
If you _do_ rely on either of the above methods or any underlying types, some
changes may be needed to your custom code. A type guard is now exported by this
package (`isOpenSearchCompatible(options)`), which you may use to help clarify
which client options are compatible with which client constructors.
If you are using this package with the `search.elasticsearch.provider` set to
`aws`, and you are making use of the `newClient` method in particular, you may
wish to start using the `@opensearch-project/opensearch` client in your custom
code.
+26
View File
@@ -0,0 +1,26 @@
---
'@backstage/plugin-home': patch
---
Added support for customizing the time format used in the `HeaderWorldClock` component
Here's an example of how this can be used in the `HomePage.tsx` found in `\packages\app\src\components\home` to change the clock to be in the 24hr time format:
```diff
+const timeFormat: Intl.DateTimeFormatOptions = {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+};
export const homePage = (
<Page themeId="home">
<Header title={<WelcomeTitle />} pageTitleOverride="Home">
+ <HeaderWorldClock clockConfigs={clockConfigs} customTimeFormat={timeFormat} />
</Header>
<Content>
<Grid container spacing={3}>
<Grid item xs={12}>
<HomePageSearchBar />
</Grid>
```
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
run: ls microsite/build/backstage && ls microsite/build/backstage/storybook
- name: Deploy both microsite and storybook to gh-pages
uses: JamesIves/github-pages-deploy-action@v4.3.3
uses: JamesIves/github-pages-deploy-action@v4.3.4
with:
branch: gh-pages
folder: microsite/build/backstage
+1
View File
@@ -184,3 +184,4 @@ _If you're using Backstage in your organization, please try to add your company
| [Funding Circle](https://www.fundingcircle.com/) | [Ariel Pacciaroni](https://github.com/arielpacciaroni) | We are building the internal developer portal using Backstage project and centralizing all services information at one place. The portal helps us track down repositories ownership as well as direct access to key information on every component. |
| [Clarivate](https://www.clarivate.com) | [Gabriele Carteni](mailto:gabriele.carteni@clarivate.com) | We are building our Developer Portal using Backstage to have a better control over our software ecosystem, integrate SDLC tools and promote best practices |
| [Cho Tot](https://www.chotot.com) | [Chotot Team](mailto:sre@chotot.vn) | Internal developer portal, service catalog with CI/CD tools. |
| [William Hill](https://www.williamhillgroup.com/) | [Pat Mills](mailto:pat.mills@williamhill.com), [Nathan Flynn](mailto:nflynn@williamhill.co.uk), and [Nishkarsh Raj](mailto:nishkarsh.raj@williamhill.co.uk) | William Hill are leveraging Backstage to build our Engineering Portal. Our mission is to centralize the software catalog inventory to enable service discoverability, reduce the onboarding time for new Engineers, provide a single pane of glass to accelerate Developer Productivity and Save Engineers time. Our aspiration is to create an InnerSource community focussed on organization-wide patterns that are re-usable and can be self-served with the Scaffolder. |
+16 -5
View File
@@ -80,15 +80,16 @@ const searchEngine = await ElasticSearchSearchEngine.initialize({
const indexBuilder = new IndexBuilder({ logger: env.logger, searchEngine });
```
For the engine to be available, your backend package needs a dependency into
For the engine to be available, your backend package needs a dependency on
package `@backstage/plugin-search-backend-module-elasticsearch`.
ElasticSearch needs some additional configuration before it is ready to use
within your instance. The configuration options are documented in the
[configuration schema definition file.](https://github.com/backstage/backstage/blob/master/plugins/search-backend-module-elasticsearch/config.d.ts)
The underlying functionality is using official ElasticSearch client version 7.x,
meaning that ElasticSearch version 7 is the only one confirmed to be supported.
The underlying functionality uses either the official ElasticSearch client
version 7.x (meaning that ElasticSearch version 7 is the only one confirmed to
be supported), or the OpenSearch client, when the `aws` provider is configured.
Should you need to create your own bespoke search experiences that require more
than just a query translator (such as faceted search or Relay pagination), you
@@ -97,9 +98,19 @@ search clients. The version of the client need not be the same as one used
internally by the elastic search engine plugin. For example:
```typescript
import { Client } from '@elastic/elastic-search';
import { isOpenSearchCompatible } from '@backstage/plugin-search-backend-module-elasticsearch';
import { Client as ElasticClient } from '@elastic/elastic-search';
import { Client as OpenSearchClient } from '@opensearch-project/opensearch';
const client = searchEngine.newClient(options => new Client(options));
const client = searchEngine.newClient(options => {
// In reality, you would only import / instantiate one of the following, but
// for illustrative purposes, here are both:
if (isOpenSearchCompatible(options)) {
return new OpenSearchClient(options);
} else {
return new ElasticClient(options);
}
});
```
#### Set custom index template
+1 -1
View File
@@ -21,7 +21,7 @@ guide to do a repository-based installation.
### Prerequisites
- Access to a Linux-based operating system, such as Linux, MacOS or
- Access to a Unix-based operating system, such as Linux, MacOS or
[Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/)
- An account with elevated rights to install the dependencies
- `curl` or `wget` installed
+131 -93
View File
@@ -8,6 +8,30 @@
const React = require('react');
const Components = require(`${process.cwd()}/core/Components.js`);
const Block = Components.Block;
const BulletLine = Components.BulletLine;
const PARTNERS = [
{
name: 'Frontside Software',
url: 'https://frontside.com/backstage/',
logo: 'img/partner-logo-frontside.png',
},
{
name: 'Roadie',
url: 'https://roadie.io/',
logo: 'img/partner-logo-roadie.png',
},
{
name: 'ThoughtWorks',
url: 'https://www.thoughtworks.com',
logo: 'img/partner-logo-thoughtworks.png',
},
{
name: 'VMWare',
url: 'https://www.vmware.com',
logo: 'img/partner-logo-vmware.png',
},
];
const Background = props => {
const { config: siteConfig } = props;
@@ -17,14 +41,11 @@ const Background = props => {
<Block small className="stripe-bottom bg-black-grey">
<Block.Container style={{ justifyContent: 'flex-start' }}>
<Block.TextBox>
<Block.Title>Backstage Community</Block.Title>
</Block.TextBox>
<Block.TextBox>
<Block.Title main>Backstage Community</Block.Title>
<Block.Paragraph>
What's the use of having fun if you can't share it? Exactly. Join
the vibrant community around the Backstage project. Be it on
GitHub, social media, Discord... You'll find a welcoming
environment. To ensure this, we follow the{' '}
Join the vibrant community around Backstage through social media
and different meetups. To ensure that you have a welcoming
environment, we follow{' '}
<a href="https://github.com/cncf/foundation/blob/master/code-of-conduct.md">
{' '}
CNCF Code of Conduct
@@ -32,110 +53,110 @@ const Background = props => {
in everything we do.
</Block.Paragraph>
</Block.TextBox>
<Block.TextBox>
<Block.TextBox style={{ margin: 'auto' }}>
<Block.Paragraph>
Main community channels
<br />- Chat and get support on our{' '}
<a href="https://discord.gg/MUpMjP2">Discord</a>
<br />- Get into contributing with the{' '}
<a href="https://github.com/backstage/backstage/contribute">
Good First Issues
</a>
<br />- Subscribe to the{' '}
<a href="https://mailchi.mp/spotify/backstage-community">
Community newsletter
</a>
<br />- Join the{' '}
<a href="https://twitter.com/i/communities/1494019781716062215">
Twitter community
</a>
<br />
<Block.SmallTitle small>
Get started in our community!
</Block.SmallTitle>
<ul>
<li>
<Block.Paragraph style={{ marginBottom: '0' }}>
Chat and get support on our{' '}
<a href="https://discord.gg/MUpMjP2">Discord</a>
</Block.Paragraph>
</li>
<li>
<Block.Paragraph style={{ marginBottom: '0' }}>
Get into contributing with the{' '}
<a href="https://github.com/backstage/backstage/contribute">
Good First Issues
</a>
</Block.Paragraph>
</li>
<li>
<Block.Paragraph style={{ marginBottom: '0' }}>
Subscribe to the{' '}
<a href="https://mailchi.mp/spotify/backstage-community">
Community newsletter
</a>
</Block.Paragraph>
</li>
<li>
<Block.Paragraph style={{ marginBottom: '0' }}>
Join the{' '}
<a href="https://twitter.com/i/communities/1494019781716062215">
Twitter community
</a>
</Block.Paragraph>
</li>
</ul>
</Block.Paragraph>
</Block.TextBox>
</Block.Container>
</Block>
<Block small className="stripe bg-black">
<Block.Container style={{ justifyContent: 'flex-start' }}>
<Block.TextBox>
<Block.Title>Backstage Community Sessions</Block.Title>
<Block.Paragraph>
Missed a meetup? Wondering when the next one is coming up? We've
got you covered! Check out our all-new Meetups page.
</Block.Paragraph>
<Block.LinkButton href="/on-demand">Meetups</Block.LinkButton>
</Block.TextBox>
<Block.TextBox>
<Block.Title>Adopter Community Sessions</Block.Title>
<Block.Paragraph>
Backstage Community Sessions is the monthly meetup where we all
come together to listen to the latest maintainer updates, learn
from each other about adopting, share exciting new demos or
discuss any relevant topic like developer effectiveness, developer
experience, developer portals, etc. Have something to share, or a
burning question? Add it to the{' '}
<a href="https://github.com/backstage/community/issues">issue</a>.
</Block.Paragraph>
</Block.TextBox>
<Block.TextBox>
<Block.Title>Contributor Community Sessions</Block.Title>
<Block.Paragraph>
Discuss all things contributing, diving deep under the hood of
Backstage (Backstage of Backstage? Backerstage?). An open
discussion with maintainers and contributors of Backstage. If you
like Backstage, this is your favorite Zoom meeting of the month,
guaranteed! Have something to share, or a burning question? Add it
to the{' '}
<a href="https://github.com/backstage/community/issues">issue</a>.
</Block.Paragraph>
<Block className="stripe-top stripe-bottom bg-teal-bottom">
<Block.Container style={{ flexFlow: 'column nowrap' }}>
<Block.TextBox wide>
<Block.Subtitle>Offical Backstage initiatives</Block.Subtitle>
<Block.Title small>
Stay tuned to the latest developments
</Block.Title>
</Block.TextBox>
<Block.Container>
<Block.TextBox style={{ flexShrink: '1' }}>
<BulletLine />
<Block.SmallTitle small>Community sessions</Block.SmallTitle>
<Block.Paragraph>
Maintainers and adopters meet monthly to share updates, demos,
and ideas. Yep, all sessions are recorded!
</Block.Paragraph>
<Block.LinkButton href="/on-demand">
Join a session
</Block.LinkButton>
</Block.TextBox>
<Block.TextBox style={{ flexShrink: '1' }}>
<BulletLine />
<Block.SmallTitle small>Newsletter</Block.SmallTitle>
<Block.Paragraph>
The official monthly Backstage newsletter. Don't miss the latest
news from your favorite project!
</Block.Paragraph>
<Block.LinkButton href="https://mailchi.mp/spotify/backstage-community">
Subscribe
</Block.LinkButton>
</Block.TextBox>
<Block.TextBox style={{ flexShrink: '1' }}>
<BulletLine />
<Block.SmallTitle small>Contributor Spotlight</Block.SmallTitle>
<Block.Paragraph>
A recognition for valuable community work. Nominate contributing
members for their efforts! We'll put them in the spotlight
</Block.Paragraph>
<Block.LinkButton href="/nominate">Nominate now</Block.LinkButton>
</Block.TextBox>
</Block.Container>
</Block.Container>
</Block>
<Block small className="stripe bg-black-grey">
<Block.Container style={{ justifyContent: 'flex-start' }}>
<Block.TextBox>
<Block.Title>Backstage official Newsletter</Block.Title>
<Block className="stripe-top bg-black">
<Block.Container wrapped style={{ justifyContent: 'flex-start' }}>
<Block.TextBox wide>
<Block.Subtitle>Community initiatives</Block.Subtitle>
</Block.TextBox>
<Block.TextBox>
<Block.TextBox small>
<Block.SmallTitle>Open Mic Meetup</Block.SmallTitle>
<Block.Paragraph>
The official monthly Backstage newsletter. Containing the latest
news from your favorite project.
</Block.Paragraph>
<Block.LinkButton href="https://mailchi.mp/spotify/backstage-community">
Subscribe
</Block.LinkButton>
</Block.TextBox>
</Block.Container>
</Block>
<Block className="stripe bg-black">
<Block.Container style={{ justifyContent: 'flex-start' }}>
<Block.TextBox>
<Block.Title>Spotlight</Block.Title>
<Block.Paragraph>
A recognition for valuable community work, the{' '}
<b>Contributor Spotlight</b>. Nominate contributing members for
their efforts! We'll put them in the spotlight .
<br />
</Block.Paragraph>
<Block.LinkButton href="nominate ">Nominate now</Block.LinkButton>
</Block.TextBox>
<Block.TextBox>
<Block.Title>Open Mic Meetup</Block.Title>
<Block.Paragraph>
A monthly casual get together of Backstage users sharing their
experiences and helping each other. Hosted by{' '}
A casual get together of Backstage users sharing their experiences
and helping each other. Hosted by{' '}
<a href="https://roadie.io/">Roadie.io</a> and{' '}
<a href="https://frontside.com/">Frontside Software</a>.
<br />
</Block.Paragraph>
<Block.LinkButton href="https://backstage-openmic.com/">
Learn more
</Block.LinkButton>
</Block.TextBox>
<Block.TextBox>
<Block.Title>Backstage Weekly</Block.Title>
<Block.TextBox small>
<Block.SmallTitle>Backstage Weekly Newsletter</Block.SmallTitle>
<Block.Paragraph>
A weekly newsletter with news, updates and things community from
your friends at <a href="https://roadie.io/">Roadie.io</a>.
@@ -146,6 +167,23 @@ const Background = props => {
</Block.TextBox>
</Block.Container>
</Block>
<Block className="stripe-top bg-black-grey">
<Block.Container wrapped style={{ justifyContent: 'flex-start' }}>
<Block.TextBox wide>
<Block.Subtitle>Commercial Partners</Block.Subtitle>
</Block.TextBox>
{PARTNERS.map(partner => (
<Block.TextBox small>
<Block.SmallTitle>
<a href={partner.url}>
<img src={`${baseUrl}${partner.logo}`} alt={partner.name} />
</a>
</Block.SmallTitle>
</Block.TextBox>
))}
</Block.Container>
</Block>
</main>
);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

+10 -1
View File
@@ -49,10 +49,19 @@ const clockConfigs: ClockConfig[] = [
},
];
const timeFormat: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
hour12: false,
};
export const homePage = (
<Page themeId="home">
<Header title={<WelcomeTitle />} pageTitleOverride="Home">
<HeaderWorldClock clockConfigs={clockConfigs} />
<HeaderWorldClock
clockConfigs={clockConfigs}
customTimeFormat={timeFormat}
/>
</Header>
<Content>
<Grid container spacing={3}>
@@ -47,7 +47,7 @@ const useIconStyles = makeStyles(
textAlign: 'center',
},
disabled: {
color: 'gray',
color: theme.palette.text.secondary,
cursor: 'default',
},
primary: {
+2 -1
View File
@@ -59,9 +59,10 @@ export function createCardExtension<T>(options: {
name?: string;
}): Extension<(props: CardExtensionProps<T>) => JSX.Element>;
// @public (undocumented)
// @public
export const HeaderWorldClock: (props: {
clockConfigs: ClockConfig[];
customTimeFormat?: Intl.DateTimeFormatOptions | undefined;
}) => JSX.Element | null;
// @public
-2
View File
@@ -16,5 +16,3 @@
export { HomepageCompositionRoot } from './HomepageCompositionRoot';
export { SettingsModal } from './SettingsModal';
export { HeaderWorldClock } from './HeaderWorldClock';
export type { ClockConfig } from './HeaderWorldClock';
@@ -0,0 +1,97 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed 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 { Header } from '@backstage/core-components';
import { wrapInTestApp } from '@backstage/test-utils';
import React, { ComponentType } from 'react';
import { ClockConfig, HeaderWorldClock } from './HeaderWorldClock';
export default {
title: 'Plugins/Home/Components/HeaderWorldClock',
decorators: [(Story: ComponentType<{}>) => wrapInTestApp(<Story />)],
};
export const Default = () => {
const clockConfigs: ClockConfig[] = [
{
label: 'NYC',
timeZone: 'America/New_York',
},
{
label: 'UTC',
timeZone: 'UTC',
},
{
label: 'STO',
timeZone: 'Europe/Stockholm',
},
{
label: 'TYO',
timeZone: 'Asia/Tokyo',
},
];
const timeFormat: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
hour12: true,
};
return (
<Header title="Header World Clock" pageTitleOverride="Home">
<HeaderWorldClock
clockConfigs={clockConfigs}
customTimeFormat={timeFormat}
/>
</Header>
);
};
export const TwentyFourHourClocks = () => {
const clockConfigs: ClockConfig[] = [
{
label: 'NYC',
timeZone: 'America/New_York',
},
{
label: 'UTC',
timeZone: 'UTC',
},
{
label: 'STO',
timeZone: 'Europe/Stockholm',
},
{
label: 'TYO',
timeZone: 'Asia/Tokyo',
},
];
const timeFormat: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
hour12: false,
};
return (
<Header title="24hr Header World Clock" pageTitleOverride="Home">
<HeaderWorldClock
clockConfigs={clockConfigs}
customTimeFormat={timeFormat}
/>
</Header>
);
};
@@ -86,3 +86,32 @@ describe('HeaderWorldClock with invalid Time Zone', () => {
expect(rendered.getByText('GMT')).toBeInTheDocument();
});
});
describe('HeaderWorldClock with custom Time Format', () => {
it('uses 24hr clock from custom Time Format', async () => {
const clockConfigs: ClockConfig[] = [
{
label: 'UTC',
timeZone: 'UTC',
},
];
const timeFormat: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
hour12: false,
};
const rendered = await renderInTestApp(
<ThemeProvider theme={lightTheme}>
<HeaderWorldClock
clockConfigs={clockConfigs}
customTimeFormat={timeFormat}
/>
</ThemeProvider>,
);
const currentTime = `${new Date().getHours()}:${new Date().getMinutes()}`;
expect(rendered.getByText(currentTime)).toBeInTheDocument();
});
});
@@ -32,7 +32,10 @@ export type ClockConfig = {
timeZone: string;
};
function getTimes(clockConfigs: ClockConfig[]) {
function getTimes(
clockConfigs: ClockConfig[],
customTimeFormat?: Intl.DateTimeFormatOptions,
) {
const d = new Date();
const lang = window.navigator.language;
@@ -47,7 +50,7 @@ function getTimes(clockConfigs: ClockConfig[]) {
const options: Intl.DateTimeFormatOptions = {
timeZone: clockConfig.timeZone,
...timeFormat,
...(customTimeFormat ?? timeFormat),
};
try {
@@ -68,24 +71,57 @@ function getTimes(clockConfigs: ClockConfig[]) {
return clocks;
}
/** @public */
export const HeaderWorldClock = (props: { clockConfigs: ClockConfig[] }) => {
const { clockConfigs } = props;
/**
* A component to display a configurable list of clocks for various time zones.
*
* @example
* Here's a simple example:
* ```
* // This will give you a clock for the time zone that Stockholm is in
* // you can add more than one but keep in mind space may be limited
* const clockConfigs: ClockConfig[] = [
* {
* label: 'STO',
* timeZone: 'Europe/Stockholm',
* },
* ];
*
* // Setting hour12 to false will make all the clocks show in the 24hr format
* const timeFormat: Intl.DateTimeFormatOptions = {
* hour: '2-digit',
* minute: '2-digit',
* hour12: false,
* };
*
* // Here is the component in use:
* <HeaderWorldClock
* clockConfigs={clockConfigs}
* customTimeFormat={timeFormat}
* />
* ```
*
* @public
*/
export const HeaderWorldClock = (props: {
clockConfigs: ClockConfig[];
customTimeFormat?: Intl.DateTimeFormatOptions;
}) => {
const { clockConfigs, customTimeFormat } = props;
const defaultTimes: TimeObj[] = [];
const [clocks, setTimes] = React.useState(defaultTimes);
React.useEffect(() => {
setTimes(getTimes(clockConfigs));
setTimes(getTimes(clockConfigs, customTimeFormat));
const intervalId = setInterval(() => {
setTimes(getTimes(clockConfigs));
setTimes(getTimes(clockConfigs, customTimeFormat));
}, 1000);
return () => {
clearInterval(intervalId);
};
}, [clockConfigs]);
}, [clockConfigs, customTimeFormat]);
if (clocks.length !== 0) {
return (
@@ -16,3 +16,4 @@
export * from './CompanyLogo';
export * from './Toolkit';
export type { ClockConfig } from './HeaderWorldClock';
+3 -2
View File
@@ -31,9 +31,10 @@ export {
ComponentTabs,
ComponentTab,
WelcomeTitle,
HeaderWorldClock,
} from './plugin';
export { SettingsModal, HeaderWorldClock } from './components';
export { SettingsModal } from './components';
export * from './assets';
export type { ClockConfig } from './components';
export type { ClockConfig } from './homePageComponents';
export { createCardExtension } from './extensions';
export type { ComponentRenderer } from './extensions';
+17
View File
@@ -136,3 +136,20 @@ export const HomePageStarredEntities = homePlugin.provide(
components: () => import('./homePageComponents/StarredEntities'),
}),
);
/**
* A component to display a configurable list of clocks for various time zones.
*
* @public
*/
export const HeaderWorldClock = homePlugin.provide(
createComponentExtension({
name: 'HeaderWorldClock',
component: {
lazy: () =>
import('./homePageComponents/HeaderWorldClock').then(
m => m.HeaderWorldClock,
),
},
}),
);
@@ -23,13 +23,14 @@ import {
} from '@backstage/catalog-model';
import { Entity } from '@backstage/catalog-model';
import { Config, JsonObject } from '@backstage/config';
import { InputError, NotFoundError } from '@backstage/errors';
import { InputError, NotFoundError, stringifyError } from '@backstage/errors';
import { ScmIntegrations } from '@backstage/integration';
import {
TemplateEntityV1beta3,
TaskSpec,
templateEntityV1beta3Validator,
} from '@backstage/plugin-scaffolder-common';
import { JsonValue } from '@backstage/types';
import express from 'express';
import Router from 'express-promise-router';
import { validate } from 'jsonschema';
@@ -431,17 +432,36 @@ function parseBearerToken(header?: string): {
token?: string;
entityRef?: string;
} {
const token = header?.match(/Bearer\s+(\S+)/i)?.[1];
if (!header) {
return {};
}
if (!token) return {};
try {
const token = header.match(/^Bearer\s(\S+\.\S+\.\S+)$/i)?.[1];
if (!token) {
throw new TypeError('Expected Bearer with JWT');
}
const [_header, rawPayload, _signature] = token.split('.');
const payload: { sub: string } = JSON.parse(
Buffer.from(rawPayload, 'base64').toString(),
);
const [_header, rawPayload, _signature] = token.split('.');
const payload: JsonValue = JSON.parse(
Buffer.from(rawPayload, 'base64').toString(),
);
return {
entityRef: payload.sub,
token,
};
if (
typeof payload !== 'object' ||
payload === null ||
Array.isArray(payload)
) {
throw new TypeError('Malformed JWT payload');
}
const sub = payload.sub;
if (typeof sub !== 'string') {
throw new TypeError('Expected string sub claim');
}
return { entityRef: sub, token };
} catch (e) {
throw new InputError(`Invalid authorization header: ${stringifyError(e)}`);
}
}
@@ -5,60 +5,27 @@
```ts
/// <reference types="node" />
import { ApiResponse } from '@opensearch-project/opensearch';
import { BatchSearchEngineIndexer } from '@backstage/plugin-search-backend-node';
import { Client } from '@elastic/elasticsearch';
import { BulkHelper } from '@opensearch-project/opensearch/lib/Helpers';
import { BulkStats } from '@opensearch-project/opensearch/lib/Helpers';
import { Config } from '@backstage/config';
import type { ConnectionOptions } from 'tls';
import { IndexableDocument } from '@backstage/plugin-search-common';
import { IndexableResultSet } from '@backstage/plugin-search-common';
import { Logger } from 'winston';
import { Readable } from 'stream';
import { SearchEngine } from '@backstage/plugin-search-common';
import { SearchQuery } from '@backstage/plugin-search-common';
// @public (undocumented)
export interface ElasticSearchAgentOptions {
// (undocumented)
keepAlive?: boolean;
// (undocumented)
keepAliveMsecs?: number;
// (undocumented)
maxFreeSockets?: number;
// (undocumented)
maxSockets?: number;
}
// @public (undocumented)
export type ElasticSearchAuth =
| {
username: string;
password: string;
}
| {
apiKey:
| string
| {
id: string;
api_key: string;
};
};
import { TransportRequestPromise } from '@opensearch-project/opensearch/lib/Transport';
// @public
export interface ElasticSearchClientOptions {
export interface BaseElasticSearchClientOptions {
// (undocumented)
agent?: ElasticSearchAgentOptions | ((opts?: any) => unknown) | false;
// (undocumented)
auth?: ElasticSearchAuth;
// (undocumented)
cloud?: {
id: string;
username?: string;
password?: string;
};
// (undocumented)
compression?: 'gzip';
// (undocumented)
Connection?: ElasticSearchConnectionConstructor;
// (undocumented)
disablePrototypePoisoningProtection?: boolean | 'proto' | 'constructor';
// (undocumented)
enableMetaHeader?: boolean;
@@ -69,28 +36,14 @@ export interface ElasticSearchClientOptions {
// (undocumented)
name?: string | symbol;
// (undocumented)
node?:
| string
| string[]
| ElasticSearchNodeOptions
| ElasticSearchNodeOptions[];
// (undocumented)
nodeFilter?: (connection: any) => boolean;
// (undocumented)
nodes?:
| string
| string[]
| ElasticSearchNodeOptions
| ElasticSearchNodeOptions[];
// (undocumented)
nodeSelector?: ((connections: any[]) => any) | string;
// (undocumented)
opaqueIdPrefix?: string;
// (undocumented)
pingTimeout?: number;
// (undocumented)
provider?: 'aws' | 'elastic';
// (undocumented)
proxy?: string | URL;
// (undocumented)
requestTimeout?: number;
@@ -112,6 +65,118 @@ export interface ElasticSearchClientOptions {
Transport?: ElasticSearchTransportConstructor;
}
// @public (undocumented)
export interface ElasticSearchAgentOptions {
// (undocumented)
keepAlive?: boolean;
// (undocumented)
keepAliveMsecs?: number;
// (undocumented)
maxFreeSockets?: number;
// (undocumented)
maxSockets?: number;
}
// @public (undocumented)
export type ElasticSearchAliasAction =
| {
remove: {
index: any;
alias: any;
};
add?: undefined;
}
| {
add: {
indices: any;
alias: any;
index?: undefined;
};
remove?: undefined;
}
| {
add: {
index: any;
alias: any;
indices?: undefined;
};
remove?: undefined;
}
| undefined;
// @public (undocumented)
export type ElasticSearchAuth =
| OpenSearchAuth
| {
apiKey:
| string
| {
id: string;
api_key: string;
};
};
// @public
export type ElasticSearchClientOptions =
| ElasticSearchElasticSearchClientOptions
| OpenSearchElasticSearchClientOptions;
// @public
export class ElasticSearchClientWrapper {
// (undocumented)
bulk(bulkOptions: {
datasource: Readable;
onDocument: () => ElasticSearchIndexAction;
refreshOnCompletion?: string | boolean;
}): BulkHelper<BulkStats>;
// (undocumented)
createIndex({
index,
}: {
index: string;
}): TransportRequestPromise<ApiResponse<Record<string, any>, unknown>>;
// (undocumented)
deleteIndex({
index,
}: {
index: string | string[];
}): TransportRequestPromise<ApiResponse<Record<string, any>, unknown>>;
// (undocumented)
static fromClientOptions(
options: ElasticSearchClientOptions,
): ElasticSearchClientWrapper;
// (undocumented)
getAliases({
aliases,
}: {
aliases: string[];
}): TransportRequestPromise<ApiResponse<Record<string, any>, unknown>>;
// (undocumented)
indexExists({
index,
}: {
index: string | string[];
}): TransportRequestPromise<ApiResponse<boolean, unknown>>;
// (undocumented)
putIndexTemplate(
template: ElasticSearchCustomIndexTemplate,
): TransportRequestPromise<ApiResponse<Record<string, any>, unknown>>;
// (undocumented)
search({
index,
body,
}: {
index: string | string[];
body: Object;
}): TransportRequestPromise<ApiResponse<Record<string, any>, unknown>>;
// (undocumented)
updateAliases({
actions,
}: {
actions: ElasticSearchAliasAction[];
}): TransportRequestPromise<ApiResponse<Record<string, any>, unknown>>;
}
// @public
export type ElasticSearchConcreteQuery = {
documentTypes?: string[];
@@ -150,6 +215,35 @@ export type ElasticSearchCustomIndexTemplateBody = {
template?: Record<string, any>;
};
// @public
export interface ElasticSearchElasticSearchClientOptions
extends BaseElasticSearchClientOptions {
// (undocumented)
auth?: ElasticSearchAuth;
// (undocumented)
cloud?: {
id: string;
username?: string;
password?: string;
};
// (undocumented)
Connection?: ElasticSearchConnectionConstructor;
// (undocumented)
node?:
| string
| string[]
| ElasticSearchNodeOptions
| ElasticSearchNodeOptions[];
// (undocumented)
nodes?:
| string
| string[]
| ElasticSearchNodeOptions
| ElasticSearchNodeOptions[];
// (undocumented)
provider?: 'elastic';
}
// @public (undocumented)
export type ElasticSearchHighlightConfig = {
fragmentDelimiter: string;
@@ -166,6 +260,14 @@ export type ElasticSearchHighlightOptions = {
numFragments?: number;
};
// @public (undocumented)
export type ElasticSearchIndexAction = {
index: {
_index: string;
[key: string]: any;
};
};
// @public (undocumented)
export interface ElasticSearchNodeOptions {
// (undocumented)
@@ -258,7 +360,7 @@ export type ElasticSearchSearchEngineIndexerOptions = {
indexSeparator: string;
alias: string;
logger: Logger;
elasticSearchClient: Client;
elasticSearchClientWrapper: ElasticSearchClientWrapper;
};
// @public (undocumented)
@@ -273,4 +375,67 @@ export interface ElasticSearchTransportConstructor {
DEFAULT: string;
};
}
// @public
export const isOpenSearchCompatible: (
opts: ElasticSearchClientOptions,
) => opts is OpenSearchElasticSearchClientOptions;
// @public (undocumented)
export type OpenSearchAuth = {
username: string;
password: string;
};
// @public (undocumented)
export interface OpenSearchConnectionConstructor {
// (undocumented)
new (opts?: any): any;
// (undocumented)
roles: {
MASTER: string;
DATA: string;
INGEST: string;
};
// (undocumented)
statuses: {
ALIVE: string;
DEAD: string;
};
}
// @public
export interface OpenSearchElasticSearchClientOptions
extends BaseElasticSearchClientOptions {
// (undocumented)
auth?: OpenSearchAuth;
// (undocumented)
connection?: OpenSearchConnectionConstructor;
// (undocumented)
node?: string | string[] | OpenSearchNodeOptions | OpenSearchNodeOptions[];
// (undocumented)
nodes?: string | string[] | OpenSearchNodeOptions | OpenSearchNodeOptions[];
// (undocumented)
provider?: 'aws';
}
// @public (undocumented)
export interface OpenSearchNodeOptions {
// (undocumented)
agent?: ElasticSearchAgentOptions;
// (undocumented)
headers?: Record<string, any>;
// (undocumented)
id?: string;
// (undocumented)
roles?: {
master: boolean;
data: boolean;
ingest: boolean;
};
// (undocumented)
ssl?: ConnectionOptions;
// (undocumented)
url: URL;
}
```
@@ -23,11 +23,12 @@
"clean": "backstage-cli package clean"
},
"dependencies": {
"@acuris/aws-es-connection": "^2.2.0",
"@backstage/config": "^1.0.1",
"@backstage/plugin-search-backend-node": "^0.6.3-next.0",
"@backstage/plugin-search-common": "^0.3.5",
"@elastic/elasticsearch": "7.13.0",
"@elastic/elasticsearch": "^7.13.0",
"@opensearch-project/opensearch": "^1.1.0",
"aws-os-connection": "^0.1.0",
"aws-sdk": "^2.948.0",
"elastic-builder": "^2.16.0",
"lodash": "^4.17.21",
@@ -37,7 +38,8 @@
"devDependencies": {
"@backstage/backend-common": "^0.14.1-next.0",
"@backstage/cli": "^0.17.3-next.0",
"@elastic/elasticsearch-mock": "^1.0.0"
"@elastic/elasticsearch-mock": "^1.0.0",
"@short.io/opensearch-mock": "^0.3.1"
},
"files": [
"dist",
@@ -16,17 +16,60 @@
import type { ConnectionOptions as TLSConnectionOptions } from 'tls';
/**
* Options used to configure the `@elastic/elasticsearch` client and
* are what will be passed as an argument to the
* {@link ElasticSearchSearchEngine.newClient} method
*
* They are drawn from the `ClientOptions` class of `@elastic/elasticsearch`,
* but are maintained separately so that this interface is not coupled to
* Typeguard to differentiate ElasticSearch client options which are compatible
* with OpenSearch vs. ElasticSearch clients. Useful when calling the
* {@link ElasticSearchSearchEngine.newClient} method.
*
* @public
*/
export interface ElasticSearchClientOptions {
provider?: 'aws' | 'elastic';
export const isOpenSearchCompatible = (
opts: ElasticSearchClientOptions,
): opts is OpenSearchElasticSearchClientOptions => {
return opts?.provider === 'aws';
};
/**
* Options used to configure the `@elastic/elasticsearch` client or the
* `@opensearch-project/opensearch` client, depending on the given config. It
* will be passed as an argument to the
* {@link ElasticSearchSearchEngine.newClient} method.
*
* @public
*/
export type ElasticSearchClientOptions =
| ElasticSearchElasticSearchClientOptions
| OpenSearchElasticSearchClientOptions;
/**
* Options used to configure the `@opensearch-project/opensearch` client.
*
* They are drawn from the `ClientOptions` class of `@opensearch-project/opensearch`,
* but are maintained separately so that this interface is not coupled to it.
*
* @public
*/
export interface OpenSearchElasticSearchClientOptions
extends BaseElasticSearchClientOptions {
provider?: 'aws';
auth?: OpenSearchAuth;
connection?: OpenSearchConnectionConstructor;
node?: string | string[] | OpenSearchNodeOptions | OpenSearchNodeOptions[];
nodes?: string | string[] | OpenSearchNodeOptions | OpenSearchNodeOptions[];
}
/**
* Options used to configure the `@elastic/elasticsearch` client.
*
* They are drawn from the `ClientOptions` class of `@elastic/elasticsearch`,
* but are maintained separately so that this interface is not coupled to it.
*
* @public
*/
export interface ElasticSearchElasticSearchClientOptions
extends BaseElasticSearchClientOptions {
provider?: 'elastic';
auth?: ElasticSearchAuth;
Connection?: ElasticSearchConnectionConstructor;
node?:
| string
| string[]
@@ -37,8 +80,21 @@ export interface ElasticSearchClientOptions {
| string[]
| ElasticSearchNodeOptions
| ElasticSearchNodeOptions[];
cloud?: {
id: string;
username?: string;
password?: string;
};
}
/**
* Base client options that are shared across `@opensearch-project/opensearch`
* and `@elastic/elasticsearch` clients.
*
* @public
*/
export interface BaseElasticSearchClientOptions {
Transport?: ElasticSearchTransportConstructor;
Connection?: ElasticSearchConnectionConstructor;
maxRetries?: number;
requestTimeout?: number;
pingTimeout?: number;
@@ -56,25 +112,24 @@ export interface ElasticSearchClientOptions {
headers?: Record<string, any>;
opaqueIdPrefix?: string;
name?: string | symbol;
auth?: ElasticSearchAuth;
proxy?: string | URL;
enableMetaHeader?: boolean;
cloud?: {
id: string;
username?: string;
password?: string;
};
disablePrototypePoisoningProtection?: boolean | 'proto' | 'constructor';
}
/**
* @public
*/
export type OpenSearchAuth = {
username: string;
password: string;
};
/**
* @public
*/
export type ElasticSearchAuth =
| {
username: string;
password: string;
}
| OpenSearchAuth
| {
apiKey:
| string
@@ -101,6 +156,22 @@ export interface ElasticSearchNodeOptions {
};
}
/**
* @public
*/
export interface OpenSearchNodeOptions {
url: URL;
id?: string;
agent?: ElasticSearchAgentOptions;
ssl?: TLSConnectionOptions;
headers?: Record<string, any>;
roles?: {
master: boolean;
data: boolean;
ingest: boolean;
};
}
/**
* @public
*/
@@ -127,6 +198,23 @@ export interface ElasticSearchConnectionConstructor {
ML: string;
};
}
/**
* @public
*/
export interface OpenSearchConnectionConstructor {
new (opts?: any): any;
statuses: {
ALIVE: string;
DEAD: string;
};
roles: {
MASTER: string;
DATA: string;
INGEST: string;
};
}
/**
* @public
*/
@@ -139,3 +227,5 @@ export interface ElasticSearchTransportConstructor {
DEFAULT: string;
};
}
// todo(iamEAP) implement canary types to ensure we remain compatible through upgrades.
@@ -0,0 +1,337 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed 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 { ConfigReader } from '@backstage/config';
import { Client as ElasticSearchClient } from '@elastic/elasticsearch';
import { Client as OpenSearchClient } from '@opensearch-project/opensearch';
import Mock from '@short.io/opensearch-mock';
import { Readable } from 'stream';
import { ElasticSearchClientWrapper } from './ElasticSearchClientWrapper';
import {
createElasticSearchClientOptions,
ElasticSearchClientOptions,
} from './ElasticSearchSearchEngine';
jest.mock('@elastic/elasticsearch', () => ({
...jest.requireActual('@elastic/elasticsearch'),
Client: jest.fn().mockReturnValue({
search: jest
.fn()
.mockImplementation(async args => ({ client: 'es', args })),
cat: {
aliases: jest
.fn()
.mockImplementation(async args => ({ client: 'es', args })),
},
helpers: {
bulk: jest
.fn()
.mockImplementation(async args => ({ client: 'es', args })),
},
indices: {
create: jest
.fn()
.mockImplementation(async args => ({ client: 'es', args })),
delete: jest
.fn()
.mockImplementation(async args => ({ client: 'es', args })),
exists: jest
.fn()
.mockImplementation(async args => ({ client: 'es', args })),
putIndexTemplate: jest
.fn()
.mockImplementation(async args => ({ client: 'es', args })),
updateAliases: jest
.fn()
.mockImplementation(async args => ({ client: 'es', args })),
},
}),
}));
jest.mock('@opensearch-project/opensearch', () => ({
...jest.requireActual('@opensearch-project/opensearch'),
Client: jest.fn().mockReturnValue({
search: jest
.fn()
.mockImplementation(async args => ({ client: 'os', args })),
cat: {
aliases: jest
.fn()
.mockImplementation(async args => ({ client: 'os', args })),
},
helpers: {
bulk: jest
.fn()
.mockImplementation(async args => ({ client: 'os', args })),
},
indices: {
create: jest
.fn()
.mockImplementation(async args => ({ client: 'os', args })),
delete: jest
.fn()
.mockImplementation(async args => ({ client: 'os', args })),
exists: jest
.fn()
.mockImplementation(async args => ({ client: 'os', args })),
putIndexTemplate: jest
.fn()
.mockImplementation(async args => ({ client: 'os', args })),
updateAliases: jest
.fn()
.mockImplementation(async args => ({ client: 'os', args })),
},
}),
}));
describe('ElasticSearchClientWrapper', () => {
describe('elasticSearchClient', () => {
let esOptions: ElasticSearchClientOptions;
beforeEach(async () => {
esOptions = await createElasticSearchClientOptions(
new ConfigReader({
node: 'http://localhost:9200',
}),
);
jest.clearAllMocks();
});
it('instantiation', () => {
ElasticSearchClientWrapper.fromClientOptions(esOptions);
expect(ElasticSearchClient).toHaveBeenCalledWith(esOptions);
});
it('search', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(esOptions);
const searchInput = { index: 'xyz', body: { eg: 'etc' } };
const result = (await wrapper.search(searchInput)) as any;
// Should call the ElasticSearch client's search with expected input.
expect(result.client).toBe('es');
expect(result.args).toStrictEqual(searchInput);
});
it('bulk', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(esOptions);
const bulkInput = {
datasource: Readable.from([{}]),
onDocument() {
return { index: { _index: 'xyz' } };
},
refreshCompletion: 'xyz',
};
const result = (await wrapper.bulk(bulkInput)) as any;
// Should call the ElasticSearch client's bulk helper with expected input
expect(result.client).toBe('es');
expect(result.args).toStrictEqual(bulkInput);
});
it('putIndexTemplate', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(esOptions);
const indexTemplate = { name: 'xyz', body: { index_patterns: ['*'] } };
const result = (await wrapper.putIndexTemplate(indexTemplate)) as any;
// Should call the ElasticSearch client with expected input.
expect(result.client).toBe('es');
expect(result.args).toStrictEqual(indexTemplate);
});
it('indexExists', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(esOptions);
const input = { index: 'xyz' };
const result = (await wrapper.indexExists(input)) as any;
// Should call the ElasticSearch client with expected input.
expect(result.client).toBe('es');
expect(result.args).toStrictEqual(input);
});
it('deleteIndex', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(esOptions);
const input = { index: 'xyz' };
const result = (await wrapper.deleteIndex(input)) as any;
// Should call the OpenSearch client with expected input.
expect(result.client).toBe('es');
expect(result.args).toStrictEqual(input);
});
it('createIndex', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(esOptions);
const input = { index: 'xyz' };
const result = (await wrapper.createIndex(input)) as any;
// Should call the OpenSearch client with expected input.
expect(result.client).toBe('es');
expect(result.args).toStrictEqual(input);
});
it('getAliases', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(esOptions);
const input = { aliases: ['xyz'] };
const result = (await wrapper.getAliases(input)) as any;
// Should call the OpenSearch client with expected input.
expect(result.client).toBe('es');
expect(result.args).toStrictEqual({
format: 'json',
name: input.aliases,
});
});
it('updateAliases', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(esOptions);
const input = { actions: [{ remove: { index: 'xyz', alias: 'abc' } }] };
const result = (await wrapper.updateAliases(input)) as any;
// Should call the OpenSearch client with expected input.
expect(result.client).toBe('es');
expect(result.args).toStrictEqual({
body: { actions: input.actions },
});
});
});
describe('openSearchClient', () => {
const mock = new Mock();
let osOptions: ElasticSearchClientOptions;
beforeEach(() => {
osOptions = {
provider: 'aws',
node: 'https://my-es-cluster.eu-west-1.es.amazonaws.com',
connection: mock.getConnection(),
};
jest.clearAllMocks();
});
it('instantiation', () => {
ElasticSearchClientWrapper.fromClientOptions(osOptions);
expect(OpenSearchClient).toHaveBeenCalledWith(osOptions);
});
it('search', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(osOptions);
const searchInput = { index: 'xyz', body: { eg: 'etc' } };
const result = (await wrapper.search(searchInput)) as any;
// Should call the OpenSearch client's search with expected input.
expect(result.client).toBe('os');
expect(result.args).toStrictEqual(searchInput);
});
it('bulk', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(osOptions);
const bulkInput = {
datasource: Readable.from([{}]),
onDocument() {
return { index: { _index: 'xyz' } };
},
refreshCompletion: 'xyz',
};
const result = (await wrapper.bulk(bulkInput)) as any;
// Should call the OpenSearch client's bulk helper with expected input.
expect(result.client).toBe('os');
expect(result.args).toStrictEqual(bulkInput);
});
it('putIndexTemplate', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(osOptions);
const indexTemplate = { name: 'xyz', body: { index_patterns: ['*'] } };
const result = (await wrapper.putIndexTemplate(indexTemplate)) as any;
// Should call the OpenSearch client with expected input.
expect(result.client).toBe('os');
expect(result.args).toStrictEqual(indexTemplate);
});
it('indexExists', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(osOptions);
const input = { index: 'xyz' };
const result = (await wrapper.indexExists(input)) as any;
// Should call the OpenSearch client with expected input.
expect(result.client).toBe('os');
expect(result.args).toStrictEqual(input);
});
it('deleteIndex', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(osOptions);
const input = { index: 'xyz' };
const result = (await wrapper.deleteIndex(input)) as any;
// Should call the OpenSearch client with expected input.
expect(result.client).toBe('os');
expect(result.args).toStrictEqual(input);
});
it('createIndex', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(osOptions);
const input = { index: 'xyz' };
const result = (await wrapper.createIndex(input)) as any;
// Should call the OpenSearch client with expected input.
expect(result.client).toBe('os');
expect(result.args).toStrictEqual(input);
});
it('getAliases', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(osOptions);
const input = { aliases: ['xyz'] };
const result = (await wrapper.getAliases(input)) as any;
// Should call the OpenSearch client with expected input.
expect(result.client).toBe('os');
expect(result.args).toStrictEqual({
format: 'json',
name: input.aliases,
});
});
it('updateAliases', async () => {
const wrapper = ElasticSearchClientWrapper.fromClientOptions(osOptions);
const input = { actions: [{ remove: { index: 'xyz', alias: 'abc' } }] };
const result = (await wrapper.updateAliases(input)) as any;
// Should call the OpenSearch client with expected input.
expect(result.client).toBe('os');
expect(result.args).toStrictEqual({
body: { actions: input.actions },
});
});
});
});
@@ -0,0 +1,209 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed 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 { Client as ElasticSearchClient } from '@elastic/elasticsearch';
import { Client as OpenSearchClient } from '@opensearch-project/opensearch';
import { Readable } from 'stream';
import {
ElasticSearchClientOptions,
isOpenSearchCompatible,
} from './ElasticSearchClientOptions';
import { ElasticSearchCustomIndexTemplate } from './types';
/**
* @public
*/
export type ElasticSearchAliasAction =
| {
remove: { index: any; alias: any };
add?: undefined;
}
| {
add: { indices: any; alias: any; index?: undefined };
remove?: undefined;
}
| {
add: { index: any; alias: any; indices?: undefined };
remove?: undefined;
}
| undefined;
/**
* @public
*/
export type ElasticSearchIndexAction = {
index: {
_index: string;
[key: string]: any;
};
};
/**
* A wrapper class that exposes logical methods that are conditionally fired
* against either a configured Elasticsearch client or a configured Opensearch
* client.
*
* This is necessary because, despite its intention to be API-compatible, the
* opensearch client does not support API key-based authentication. This is
* also the sanest way to accomplish this while making typescript happy.
*
* In the future, if the differences between implementations become
* unmaintainably divergent, we should split out the Opensearch and
* Elasticsearch search engine implementations.
*
* @public
*/
export class ElasticSearchClientWrapper {
private readonly elasticSearchClient: ElasticSearchClient | undefined;
private readonly openSearchClient: OpenSearchClient | undefined;
private constructor({
openSearchClient,
elasticSearchClient,
}: {
openSearchClient?: OpenSearchClient;
elasticSearchClient?: ElasticSearchClient;
}) {
this.openSearchClient = openSearchClient;
this.elasticSearchClient = elasticSearchClient;
}
static fromClientOptions(options: ElasticSearchClientOptions) {
if (isOpenSearchCompatible(options)) {
return new ElasticSearchClientWrapper({
openSearchClient: new OpenSearchClient(options),
});
}
return new ElasticSearchClientWrapper({
elasticSearchClient: new ElasticSearchClient(options),
});
}
search({ index, body }: { index: string | string[]; body: Object }) {
if (this.openSearchClient) {
return this.openSearchClient.search({ index, body });
}
if (this.elasticSearchClient) {
return this.elasticSearchClient.search({ index, body });
}
throw new Error('No client defined');
}
bulk(bulkOptions: {
datasource: Readable;
onDocument: () => ElasticSearchIndexAction;
refreshOnCompletion?: string | boolean;
}) {
if (this.openSearchClient) {
return this.openSearchClient.helpers.bulk(bulkOptions);
}
if (this.elasticSearchClient) {
return this.elasticSearchClient.helpers.bulk(bulkOptions);
}
throw new Error('No client defined');
}
putIndexTemplate(template: ElasticSearchCustomIndexTemplate) {
if (this.openSearchClient) {
return this.openSearchClient.indices.putIndexTemplate(template);
}
if (this.elasticSearchClient) {
return this.elasticSearchClient.indices.putIndexTemplate(template);
}
throw new Error('No client defined');
}
indexExists({ index }: { index: string | string[] }) {
if (this.openSearchClient) {
return this.openSearchClient.indices.exists({ index });
}
if (this.elasticSearchClient) {
return this.elasticSearchClient.indices.exists({ index });
}
throw new Error('No client defined');
}
deleteIndex({ index }: { index: string | string[] }) {
if (this.openSearchClient) {
return this.openSearchClient.indices.delete({ index });
}
if (this.elasticSearchClient) {
return this.elasticSearchClient.indices.delete({ index });
}
throw new Error('No client defined');
}
createIndex({ index }: { index: string }) {
if (this.openSearchClient) {
return this.openSearchClient.indices.create({ index });
}
if (this.elasticSearchClient) {
return this.elasticSearchClient.indices.create({ index });
}
throw new Error('No client defined');
}
getAliases({ aliases }: { aliases: string[] }) {
if (this.openSearchClient) {
return this.openSearchClient.cat.aliases({
format: 'json',
name: aliases,
});
}
if (this.elasticSearchClient) {
return this.elasticSearchClient.cat.aliases({
format: 'json',
name: aliases,
});
}
throw new Error('No client defined');
}
updateAliases({ actions }: { actions: ElasticSearchAliasAction[] }) {
const filteredActions = actions.filter(Boolean);
if (this.openSearchClient) {
return this.openSearchClient.indices.updateAliases({
body: {
actions: filteredActions,
},
});
}
if (this.elasticSearchClient) {
return this.elasticSearchClient.indices.updateAliases({
body: {
actions: filteredActions,
},
});
}
throw new Error('No client defined');
}
}
@@ -16,8 +16,9 @@
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { Client, errors } from '@elastic/elasticsearch';
import { errors } from '@elastic/elasticsearch';
import Mock from '@elastic/elasticsearch-mock';
import { ElasticSearchClientWrapper } from './ElasticSearchClientWrapper';
import {
ElasticSearchConcreteQuery,
decodePageCursor,
@@ -70,7 +71,7 @@ const customIndexTemplate = {
describe('ElasticSearchSearchEngine', () => {
let testSearchEngine: ElasticSearchSearchEngine;
let inspectableSearchEngine: ElasticSearchSearchEngineForTranslatorTests;
let client: Client;
let clientWrapper: ElasticSearchClientWrapper;
beforeEach(() => {
testSearchEngine = new ElasticSearchSearchEngine(
@@ -86,7 +87,7 @@ describe('ElasticSearchSearchEngine', () => {
getVoidLogger(),
);
// eslint-disable-next-line dot-notation
client = testSearchEngine['elasticSearchClient'];
clientWrapper = testSearchEngine['elasticSearchClientWrapper'];
});
describe('custom index template', () => {
@@ -686,7 +687,7 @@ describe('ElasticSearchSearchEngine', () => {
});
it('should handle index/search type filtering correctly', async () => {
const elasticSearchQuerySpy = jest.spyOn(client, 'search');
const elasticSearchQuerySpy = jest.spyOn(clientWrapper, 'search');
await testSearchEngine.query({
term: 'testTerm',
filters: {},
@@ -725,7 +726,7 @@ describe('ElasticSearchSearchEngine', () => {
});
it('should create matchAll query if no term defined', async () => {
const elasticSearchQuerySpy = jest.spyOn(client, 'search');
const elasticSearchQuerySpy = jest.spyOn(clientWrapper, 'search');
await testSearchEngine.query({
term: '',
filters: {},
@@ -759,7 +760,7 @@ describe('ElasticSearchSearchEngine', () => {
});
it('should query only specified indices if defined', async () => {
const elasticSearchQuerySpy = jest.spyOn(client, 'search');
const elasticSearchQuerySpy = jest.spyOn(clientWrapper, 'search');
await testSearchEngine.query({
term: '',
filters: {},
@@ -809,7 +810,7 @@ describe('ElasticSearchSearchEngine', () => {
type: 'test-index',
indexPrefix: '',
indexSeparator: '-index__',
elasticSearchClient: client,
elasticSearchClientWrapper: clientWrapper,
}),
);
expect(indexerMock.on).toHaveBeenCalledWith(
@@ -14,10 +14,7 @@
* limitations under the License.
*/
import {
awsGetCredentials,
createAWSConnection,
} from '@acuris/aws-es-connection';
import { awsGetCredentials, createAWSConnection } from 'aws-os-connection';
import { Config } from '@backstage/config';
import {
IndexableDocument,
@@ -26,47 +23,17 @@ import {
SearchEngine,
SearchQuery,
} from '@backstage/plugin-search-common';
import { Client } from '@elastic/elasticsearch';
import esb from 'elastic-builder';
import { isEmpty, isNaN as nan, isNumber } from 'lodash';
import { v4 as uuid } from 'uuid';
import { Logger } from 'winston';
import type { ElasticSearchClientOptions } from './ElasticSearchClientOptions';
import { ElasticSearchClientOptions } from './ElasticSearchClientOptions';
import { ElasticSearchSearchEngineIndexer } from './ElasticSearchSearchEngineIndexer';
import { ElasticSearchCustomIndexTemplate } from './types';
import { ElasticSearchClientWrapper } from './ElasticSearchClientWrapper';
export type { ElasticSearchClientOptions };
/**
* Elasticsearch specific index template
* @public
*/
export type ElasticSearchCustomIndexTemplate = {
name: string;
body: ElasticSearchCustomIndexTemplateBody;
};
/**
* Elasticsearch specific index template body
* @public
*/
export type ElasticSearchCustomIndexTemplateBody = {
/**
* Array of wildcard (*) expressions used to match the names of data streams and indices during creation.
*/
index_patterns: string[];
/**
* An ordered list of component template names.
* Component templates are merged in the order specified,
* meaning that the last component template specified has the highest precedence.
*/
composed_of?: string[];
/**
* See available properties of template
* https://www.elastic.co/guide/en/elasticsearch/reference/7.15/indices-put-template.html#put-index-template-api-request-body
*/
template?: Record<string, any>;
};
/**
* Search query that the elasticsearch engine understands.
* @public
@@ -143,7 +110,7 @@ function isBlank(str: string) {
* @public
*/
export class ElasticSearchSearchEngine implements SearchEngine {
private readonly elasticSearchClient: Client;
private readonly elasticSearchClientWrapper: ElasticSearchClientWrapper;
private readonly highlightOptions: ElasticSearchHighlightConfig;
constructor(
@@ -153,7 +120,8 @@ export class ElasticSearchSearchEngine implements SearchEngine {
private readonly logger: Logger,
highlightOptions?: ElasticSearchHighlightOptions,
) {
this.elasticSearchClient = this.newClient(options => new Client(options));
this.elasticSearchClientWrapper =
ElasticSearchClientWrapper.fromClientOptions(elasticSearchClientOptions);
const uuidTag = uuid();
this.highlightOptions = {
preTag: `<${uuidTag}>`,
@@ -177,7 +145,7 @@ export class ElasticSearchSearchEngine implements SearchEngine {
if (options.provider === 'elastic') {
logger.info('Initializing Elastic.co ElasticSearch search engine.');
} else if (options.provider === 'aws') {
logger.info('Initializing AWS ElasticSearch search engine.');
logger.info('Initializing AWS OpenSearch search engine.');
} else {
logger.info('Initializing ElasticSearch search engine.');
}
@@ -269,7 +237,7 @@ export class ElasticSearchSearchEngine implements SearchEngine {
async setIndexTemplate(template: ElasticSearchCustomIndexTemplate) {
try {
await this.elasticSearchClient.indices.putIndexTemplate(template);
await this.elasticSearchClientWrapper.putIndexTemplate(template);
this.logger.info('Custom index template set');
} catch (error) {
this.logger.error(`Unable to set custom index template: ${error}`);
@@ -284,7 +252,7 @@ export class ElasticSearchSearchEngine implements SearchEngine {
indexPrefix: this.indexPrefix,
indexSeparator: this.indexSeparator,
alias,
elasticSearchClient: this.elasticSearchClient,
elasticSearchClientWrapper: this.elasticSearchClientWrapper,
logger: this.logger,
});
@@ -292,13 +260,13 @@ export class ElasticSearchSearchEngine implements SearchEngine {
indexer.on('error', async e => {
this.logger.error(`Failed to index documents for type ${type}`, e);
try {
const response = await this.elasticSearchClient.indices.exists({
const response = await this.elasticSearchClientWrapper.indexExists({
index: indexer.indexName,
});
const indexCreated = response.body;
if (indexCreated) {
this.logger.info(`Removing created index ${indexer.indexName}`);
await this.elasticSearchClient.indices.delete({
await this.elasticSearchClientWrapper.deleteIndex({
index: indexer.indexName,
});
}
@@ -319,7 +287,7 @@ export class ElasticSearchSearchEngine implements SearchEngine {
? documentTypes.map(it => this.constructSearchAlias(it))
: this.constructSearchAlias('*');
try {
const result = await this.elasticSearchClient.search({
const result = await this.elasticSearchClientWrapper.search({
index: queryIndices,
body: elasticSearchQuery,
});
@@ -398,7 +366,7 @@ export function encodePageCursor({ page }: { page: number }): string {
return Buffer.from(`${page}`, 'utf-8').toString('base64');
}
async function createElasticSearchClientOptions(
export async function createElasticSearchClientOptions(
config?: Config,
): Promise<ElasticSearchClientOptions> {
if (!config) {
@@ -16,13 +16,13 @@
import { getVoidLogger } from '@backstage/backend-common';
import { TestPipeline } from '@backstage/plugin-search-backend-node';
import { Client } from '@elastic/elasticsearch';
import Mock from '@elastic/elasticsearch-mock';
import { range } from 'lodash';
import { ElasticSearchClientWrapper } from './ElasticSearchClientWrapper';
import { ElasticSearchSearchEngineIndexer } from './ElasticSearchSearchEngineIndexer';
const mock = new Mock();
const client = new Client({
const clientWrapper = ElasticSearchClientWrapper.fromClientOptions({
node: 'http://localhost:9200',
Connection: mock.getConnection(),
});
@@ -43,7 +43,7 @@ describe('ElasticSearchSearchEngineIndexer', () => {
indexSeparator: '-index__',
alias: 'some-type-index__search',
logger: getVoidLogger(),
elasticSearchClient: client,
elasticSearchClientWrapper: clientWrapper,
});
// Set up all requisite Elastic mocks.
@@ -16,9 +16,9 @@
import { BatchSearchEngineIndexer } from '@backstage/plugin-search-backend-node';
import { IndexableDocument } from '@backstage/plugin-search-common';
import { Client } from '@elastic/elasticsearch';
import { Readable } from 'stream';
import { Logger } from 'winston';
import { ElasticSearchClientWrapper } from './ElasticSearchClientWrapper';
/**
* Options for instansiate ElasticSearchSearchEngineIndexer
@@ -30,7 +30,7 @@ export type ElasticSearchSearchEngineIndexerOptions = {
indexSeparator: string;
alias: string;
logger: Logger;
elasticSearchClient: Client;
elasticSearchClientWrapper: ElasticSearchClientWrapper;
};
function duration(startTimestamp: [number, number]): string {
@@ -57,7 +57,7 @@ export class ElasticSearchSearchEngineIndexer extends BatchSearchEngineIndexer {
private readonly removableAlias: string;
private readonly logger: Logger;
private readonly sourceStream: Readable;
private readonly elasticSearchClient: Client;
private readonly elasticSearchClientWrapper: ElasticSearchClientWrapper;
private bulkResult: Promise<any>;
constructor(options: ElasticSearchSearchEngineIndexerOptions) {
@@ -70,7 +70,7 @@ export class ElasticSearchSearchEngineIndexer extends BatchSearchEngineIndexer {
this.indexName = this.constructIndexName(`${Date.now()}`);
this.alias = options.alias;
this.removableAlias = `${this.alias}_removable`;
this.elasticSearchClient = options.elasticSearchClient;
this.elasticSearchClientWrapper = options.elasticSearchClientWrapper;
// The ES client bulk helper supports stream-based indexing, but we have to
// supply the stream directly to it at instantiation-time. We can't supply
@@ -83,7 +83,7 @@ export class ElasticSearchSearchEngineIndexer extends BatchSearchEngineIndexer {
// Keep a reference to the ES Bulk helper so that we can know when all
// documents have been successfully written to ES.
this.bulkResult = this.elasticSearchClient.helpers.bulk({
this.bulkResult = this.elasticSearchClientWrapper.bulk({
datasource: this.sourceStream,
onDocument() {
that.processed++;
@@ -98,16 +98,15 @@ export class ElasticSearchSearchEngineIndexer extends BatchSearchEngineIndexer {
async initialize(): Promise<void> {
this.logger.info(`Started indexing documents for index ${this.type}`);
const aliases = await this.elasticSearchClient.cat.aliases({
format: 'json',
name: [this.alias, this.removableAlias],
const aliases = await this.elasticSearchClientWrapper.getAliases({
aliases: [this.alias, this.removableAlias],
});
this.removableIndices = [
...new Set(aliases.body.map((r: Record<string, any>) => r.index)),
] as string[];
await this.elasticSearchClient.indices.create({
await this.elasticSearchClientWrapper.createIndex({
index: this.indexName,
});
}
@@ -140,25 +139,23 @@ export class ElasticSearchSearchEngineIndexer extends BatchSearchEngineIndexer {
)}`,
result,
);
await this.elasticSearchClient.indices.updateAliases({
body: {
actions: [
{
remove: { index: this.constructIndexName('*'), alias: this.alias },
},
this.removableIndices.length
? {
add: {
indices: this.removableIndices,
alias: this.removableAlias,
},
}
: undefined,
{
add: { index: this.indexName, alias: this.alias },
},
].filter(Boolean),
},
await this.elasticSearchClientWrapper.updateAliases({
actions: [
{
remove: { index: this.constructIndexName('*'), alias: this.alias },
},
this.removableIndices.length
? {
add: {
indices: this.removableIndices,
alias: this.removableAlias,
},
}
: undefined,
{
add: { index: this.indexName, alias: this.alias },
},
].filter(Boolean),
});
// If any indices are removable, remove them. Do not bubble up this error,
@@ -166,7 +163,7 @@ export class ElasticSearchSearchEngineIndexer extends BatchSearchEngineIndexer {
if (this.removableIndices.length) {
this.logger.info('Removing stale search indices', this.removableIndices);
try {
await this.elasticSearchClient.indices.delete({
await this.elasticSearchClientWrapper.deleteIndex({
index: this.removableIndices,
});
} catch (e) {
@@ -15,13 +15,25 @@
*/
export { ElasticSearchSearchEngine } from './ElasticSearchSearchEngine';
export { isOpenSearchCompatible } from './ElasticSearchClientOptions';
export type {
BaseElasticSearchClientOptions,
ElasticSearchAgentOptions,
ElasticSearchConnectionConstructor,
ElasticSearchElasticSearchClientOptions,
ElasticSearchTransportConstructor,
ElasticSearchNodeOptions,
ElasticSearchAuth,
OpenSearchAuth,
OpenSearchConnectionConstructor,
OpenSearchElasticSearchClientOptions,
OpenSearchNodeOptions,
} from './ElasticSearchClientOptions';
export type {
ElasticSearchAliasAction,
ElasticSearchClientWrapper,
ElasticSearchIndexAction,
} from './ElasticSearchClientWrapper';
export type {
ElasticSearchConcreteQuery,
ElasticSearchClientOptions,
@@ -30,10 +42,12 @@ export type {
ElasticSearchQueryTranslator,
ElasticSearchQueryTranslatorOptions,
ElasticSearchOptions,
ElasticSearchCustomIndexTemplate,
ElasticSearchCustomIndexTemplateBody,
} from './ElasticSearchSearchEngine';
export type {
ElasticSearchSearchEngineIndexer,
ElasticSearchSearchEngineIndexerOptions,
} from './ElasticSearchSearchEngineIndexer';
export type {
ElasticSearchCustomIndexTemplate,
ElasticSearchCustomIndexTemplateBody,
} from './types';
@@ -0,0 +1,46 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed 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.
*/
/**
* Elasticsearch specific index template
* @public
*/
export type ElasticSearchCustomIndexTemplate = {
name: string;
body: ElasticSearchCustomIndexTemplateBody;
};
/**
* Elasticsearch specific index template body
* @public
*/
export type ElasticSearchCustomIndexTemplateBody = {
/**
* Array of wildcard (*) expressions used to match the names of data streams and indices during creation.
*/
index_patterns: string[];
/**
* An ordered list of component template names.
* Component templates are merged in the order specified,
* meaning that the last component template specified has the highest precedence.
*/
composed_of?: string[];
/**
* See available properties of template
* https://www.elastic.co/guide/en/elasticsearch/reference/7.15/indices-put-template.html#put-index-template-api-request-body
*/
template?: Record<string, any>;
};
@@ -20,13 +20,18 @@
* @packageDocumentation
*/
export { ElasticSearchSearchEngine } from './engines';
export { ElasticSearchSearchEngine, isOpenSearchCompatible } from './engines';
export type {
BaseElasticSearchClientOptions,
ElasticSearchAgentOptions,
ElasticSearchAliasAction,
ElasticSearchClientWrapper,
ElasticSearchConcreteQuery,
ElasticSearchClientOptions,
ElasticSearchElasticSearchClientOptions,
ElasticSearchHighlightConfig,
ElasticSearchHighlightOptions,
ElasticSearchIndexAction,
ElasticSearchQueryTranslator,
ElasticSearchQueryTranslatorOptions,
ElasticSearchConnectionConstructor,
@@ -38,4 +43,8 @@ export type {
ElasticSearchSearchEngineIndexerOptions,
ElasticSearchCustomIndexTemplate,
ElasticSearchCustomIndexTemplateBody,
OpenSearchAuth,
OpenSearchConnectionConstructor,
OpenSearchElasticSearchClientOptions,
OpenSearchNodeOptions,
} from './engines';
+36 -17
View File
@@ -2,13 +2,6 @@
# yarn lockfile v1
"@acuris/aws-es-connection@^2.2.0":
version "2.3.0"
resolved "https://registry.npmjs.org/@acuris/aws-es-connection/-/aws-es-connection-2.3.0.tgz#bfa736163bdfe2e7fc12f8529eead949fb4e73fe"
integrity sha512-k8jSkdNwpSPty7oautGaJXoAzzPjP7WKbmaAE9i+vJrKWg9n2UtjesHeDhOAPzV9MG8EzXofGozYjaLdeTP+Ug==
dependencies:
aws4 "^1.11.0"
"@ampproject/remapping@^2.1.0":
version "2.1.2"
resolved "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.2.tgz#4edca94973ded9630d20101cd8559cedb8d8bd34"
@@ -2079,10 +2072,10 @@
find-my-way "^4.3.3"
into-stream "^6.0.0"
"@elastic/elasticsearch@7.13.0":
version "7.13.0"
resolved "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-7.13.0.tgz#6dcf511dfa91187e22c81e54f41f4bd0fd96b4d6"
integrity sha512-WgwLWo2p9P2tdqzBGX9fHeG8p5IOTXprXNTECQG2mJ7z9n93N5AFBJpEw4d35tWWeCWi9jI13A2wzQZH7XZ/xw==
"@elastic/elasticsearch@^7.13.0":
version "7.17.0"
resolved "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-7.17.0.tgz#589fb219234cf1b0da23744e82b1d25e2fe9a797"
integrity sha512-5QLPCjd0uLmLj1lSuKSThjNpq39f6NmlTy9ROLFwG5gjyTgpwSqufDeYG/Fm43Xs05uF7WcscoO7eguI3HuuYA==
dependencies:
debug "^4.3.1"
hpagent "^0.1.1"
@@ -5127,6 +5120,16 @@
rxjs "7.5.5"
tslib "2.0.3"
"@opensearch-project/opensearch@^1.1.0":
version "1.1.0"
resolved "https://registry.npmjs.org/@opensearch-project/opensearch/-/opensearch-1.1.0.tgz#8b3c8b4cbcea01755ba092d2997bf0b4ca7f22f7"
integrity sha512-1TDw92JL8rD1b2QGluqBsIBLIiD5SGciIpz4qkrGAe9tcdfQ1ptub5e677rhWl35UULSjr6hP8M6HmISZ/M5HQ==
dependencies:
debug "^4.3.1"
hpagent "^0.1.1"
ms "^2.1.3"
secure-json-parse "^2.4.0"
"@opentelemetry/api@^1.0.1":
version "1.0.4"
resolved "https://registry.npmjs.org/@opentelemetry/api/-/api-1.0.4.tgz#a167e46c10d05a07ab299fc518793b0cff8f6924"
@@ -5203,9 +5206,9 @@
"@react-hookz/deep-equal" "^1.0.1"
"@react-hookz/web@^14.0.0":
version "14.2.2"
resolved "https://registry.npmjs.org/@react-hookz/web/-/web-14.2.2.tgz#eee0085f954e5b62d0a6c5b20d8786c28abfb9ad"
integrity sha512-6FeZHKV3FFOYnbSJMkkqXDoMuIX0HEFJlDF4LpHR7RsA3zrH2xzhtF8nSYZ+pG6fDIqYKID9yQuILFmbAWaqCQ==
version "14.3.0"
resolved "https://registry.npmjs.org/@react-hookz/web/-/web-14.3.0.tgz#ccfcfa65aa1e56ca5a3b258a9bd987e0daa67bae"
integrity sha512-lmZ9rAoHbBFZo0v72cjHMCY/hz9Wp6WQd/Kx53A+HumxkAaJT3AuBkd9jJgrtdsNiHdG0IEr93/oOza3+G6kVA==
dependencies:
"@react-hookz/deep-equal" "^1.0.2"
@@ -5420,6 +5423,15 @@
dependencies:
any-observable "^0.3.0"
"@short.io/opensearch-mock@^0.3.1":
version "0.3.1"
resolved "https://registry.npmjs.org/@short.io/opensearch-mock/-/opensearch-mock-0.3.1.tgz#41678404b94342ac60263a8df590f9b1efa80622"
integrity sha512-gwLTzxJrZNGVQNn+FHLhpw7at3jHb6tNczq2hvFPJ3C3nLaCBRkbP2IQHrIDzklLBVFV83Ug0Xgs73A5o9eLtg==
dependencies:
fast-deep-equal "^3.1.3"
find-my-way "^4.3.3"
into-stream "^6.0.0"
"@sideway/address@^4.1.0":
version "4.1.1"
resolved "https://registry.npmjs.org/@sideway/address/-/address-4.1.1.tgz#9e321e74310963fdf8eebfbee09c7bd69972de4d"
@@ -8250,6 +8262,13 @@ available-typed-arrays@^1.0.2:
dependencies:
array-filter "^1.0.0"
aws-os-connection@^0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/aws-os-connection/-/aws-os-connection-0.1.0.tgz#02c1be08cf63448c5bdc9d47b03b7ad3222b662c"
integrity sha512-WBN2S/5zgzfPpEiOTEW7IPJX+hlq+AQeAVKK4AL/Th0SfRRKu6a46i4aZKovQslBkKnmcSFvy+mHSJ299N8YWA==
dependencies:
aws4 "^1.11.0"
aws-sdk-mock@^5.2.1:
version "5.7.0"
resolved "https://registry.npmjs.org/aws-sdk-mock/-/aws-sdk-mock-5.7.0.tgz#b2a9454c78d008186c3f1a460b79cdb9cc9dfdc4"
@@ -9245,9 +9264,9 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001286:
integrity sha512-5v7LFQU4Sb/qvkz7JcZkvtSH1Ko+1x2kgo3ocdBeMGZSOFpuE1kkm0kpTwLtWeFrw5qw08ulLxJjVIXIS8MkiQ==
canvas@^2.6.1:
version "2.9.1"
resolved "https://registry.npmjs.org/canvas/-/canvas-2.9.1.tgz#58ec841cba36cef0675bc7a74ebd1561f0b476b0"
integrity sha512-vSQti1uG/2gjv3x6QLOZw7TctfufaerTWbVe+NSduHxxLGB+qf3kFgQ6n66DSnuoINtVUjrLLIK2R+lxrBG07A==
version "2.9.3"
resolved "https://registry.npmjs.org/canvas/-/canvas-2.9.3.tgz#8723c4f970442d4cdcedba5221579f9660a58bdb"
integrity sha512-WOUM7ghii5TV2rbhaZkh1youv/vW1/Canev6Yx6BG2W+1S07w8jKZqKkPnbiPpQEDsnJdN8ouDd7OvQEGXDcUw==
dependencies:
"@mapbox/node-pre-gyp" "^1.0.0"
nan "^2.15.0"