Merge branch 'backstage:master' into feature/support-aoss
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': patch
|
||||
---
|
||||
|
||||
Added `createMockDirectory()` to help out with file system mocking in tests.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Properly close write stream when writing temporary archive for processing zip-based `.readTree()` responses.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Removed `mock-fs` dev dependency.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-backend': patch
|
||||
'@backstage/plugin-kubernetes-cluster': patch
|
||||
'@backstage/plugin-kubernetes-react': patch
|
||||
'@backstage/plugin-kubernetes': patch
|
||||
---
|
||||
|
||||
Add Kubernetes cluster plugin. Viewing Kubernetes clusters as an Admin from Backstage
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-node': patch
|
||||
---
|
||||
|
||||
Fixed cookie persisted scope not returned in OAuth refresh handler response.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-backend': patch
|
||||
'@backstage/plugin-kubernetes-react': patch
|
||||
'@backstage/plugin-kubernetes-common': patch
|
||||
---
|
||||
|
||||
The kubernetes APIs invokes Authentication Strategies when Backstage-Kubernetes-Authorization-X-X headers are provided, this enable the possibility to invoke strategies that executes additional steps to get a kubernetes token like on pinniped or custom strategies
|
||||
@@ -9,9 +9,10 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-2019
|
||||
runs-on: windows-2022
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [18.x, 20.x]
|
||||
|
||||
|
||||
@@ -73,16 +73,18 @@ Team: @backstage/permission-maintainers
|
||||
|
||||
Scope: The Permission Framework and plugins integrating with the permission framework
|
||||
|
||||
| Name | Organization | Team | GitHub | Discord |
|
||||
| -------------------- | ------------ | --------------- | ---------------------------------------------- | ---------------- |
|
||||
| Ainhoa Larumbe | Spotify | Imaginary Goats | [ainhoaL](http://github.com/ainhoaL) | ainhoa#8085 |
|
||||
| Claire Casey | Spotify | Imaginary Goats | [clairelcasey](http://github.com/clairelcasey) | clairecasey#2710 |
|
||||
| Eric Peterson | Spotify | Imaginary Goats | [iamEAP](http://github.com/iamEAP) | iamEAP#3058 |
|
||||
| Harry Hogg | Spotify | Imaginary Goats | [HHogg](http://github.com/HHogg) | simplex#3451 |
|
||||
| Joon Park | Spotify | Imaginary Goats | [Joonpark13](http://github.com/Joonpark13) | Sixpool#5060 |
|
||||
| Mike Lewis | Spotify | Imaginary Goats | [mtlewis](http://github.com/mtlewis) | mtlewis#3658 |
|
||||
| Tim Hansen | Spotify | Imaginary Goats | [timbonicus](http://github.com/timbonicus) | timbonicus#6871 |
|
||||
| Vincenzo Scamporlino | Spotify | Imaginary Goats | [vinzscam](http://github.com/vinzscam) | vinzscam#6944 |
|
||||
| Name | Organization | Team | GitHub | Discord |
|
||||
| -------------------- | ------------ | --------------- | ----------------------------------------------- | ---------------- |
|
||||
| Ainhoa Larumbe | Spotify | Imaginary Goats | [ainhoaL](http://github.com/ainhoaL) | ainhoa#8085 |
|
||||
| Aramis Sennyey | Spotify | Imaginary Goats | [sennyeya](https://github.com/sennyeya) | Aramis#7984 |
|
||||
| Claire Casey | Spotify | Imaginary Goats | [clairelcasey](http://github.com/clairelcasey) | clairecasey#2710 |
|
||||
| Eric Peterson | Spotify | Imaginary Goats | [iamEAP](http://github.com/iamEAP) | iamEAP#3058 |
|
||||
| Harry Hogg | Spotify | Imaginary Goats | [HHogg](http://github.com/HHogg) | simplex#3451 |
|
||||
| Joon Park | Spotify | Imaginary Goats | [Joonpark13](http://github.com/Joonpark13) | Sixpool#5060 |
|
||||
| Lynette Lopez | Spotify | Imaginary Goats | [lynettelopez](https://github.com/lynettelopez) | lynettelopez |
|
||||
| Mike Lewis | Spotify | Imaginary Goats | [mtlewis](http://github.com/mtlewis) | mtlewis#3658 |
|
||||
| Tim Hansen | Spotify | Imaginary Goats | [timbonicus](http://github.com/timbonicus) | timbonicus#6871 |
|
||||
| Vincenzo Scamporlino | Spotify | Imaginary Goats | [vinzscam](http://github.com/vinzscam) | vinzscam#6944 |
|
||||
|
||||
### TechDocs
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@backstage/plugin-jenkins": "workspace:^",
|
||||
"@backstage/plugin-kafka": "workspace:^",
|
||||
"@backstage/plugin-kubernetes": "workspace:^",
|
||||
"@backstage/plugin-kubernetes-cluster": "workspace:^",
|
||||
"@backstage/plugin-lighthouse": "workspace:^",
|
||||
"@backstage/plugin-linguist": "workspace:^",
|
||||
"@backstage/plugin-linguist-common": "workspace:^",
|
||||
|
||||
@@ -103,6 +103,10 @@ import {
|
||||
} from '@backstage/plugin-jenkins';
|
||||
import { EntityKafkaContent } from '@backstage/plugin-kafka';
|
||||
import { EntityKubernetesContent } from '@backstage/plugin-kubernetes';
|
||||
import {
|
||||
isKubernetesClusterAvailable,
|
||||
EntityKubernetesClusterContent,
|
||||
} from '@backstage/plugin-kubernetes-cluster';
|
||||
import {
|
||||
EntityLastLighthouseAuditCard,
|
||||
EntityLighthouseContent,
|
||||
@@ -898,6 +902,13 @@ const resourcePage = (
|
||||
</Grid>
|
||||
</Grid>
|
||||
</EntityLayout.Route>
|
||||
<EntityLayout.Route
|
||||
path="/kubernetes-cluster"
|
||||
title="Kubernetes Cluster"
|
||||
if={isKubernetesClusterAvailable}
|
||||
>
|
||||
<EntityKubernetesClusterContent />
|
||||
</EntityLayout.Route>
|
||||
<EntityLayout.Route
|
||||
path="/puppetdb"
|
||||
title="Puppet"
|
||||
|
||||
@@ -125,7 +125,6 @@
|
||||
"@types/fs-extra": "^9.0.3",
|
||||
"@types/http-errors": "^2.0.0",
|
||||
"@types/minimist": "^1.2.0",
|
||||
"@types/mock-fs": "^4.13.0",
|
||||
"@types/morgan": "^1.9.0",
|
||||
"@types/node-forge": "^1.3.0",
|
||||
"@types/pg": "^8.6.6",
|
||||
@@ -138,7 +137,6 @@
|
||||
"aws-sdk-client-mock": "^2.0.0",
|
||||
"better-sqlite3": "^8.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
"msw": "^1.0.0",
|
||||
"mysql2": "^2.2.5",
|
||||
"recursive-readdir": "^2.2.2",
|
||||
|
||||
@@ -23,12 +23,13 @@ import {
|
||||
AzureDevOpsCredentialLike,
|
||||
AzureIntegrationConfig,
|
||||
} from '@backstage/integration';
|
||||
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import {
|
||||
createMockDirectory,
|
||||
setupRequestMockHandlers,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import * as os from 'os';
|
||||
import path from 'path';
|
||||
import { NotModifiedError } from '@backstage/errors';
|
||||
import { getVoidLogger } from '../logging';
|
||||
@@ -43,6 +44,8 @@ type AzureIntegrationConfigLike = Partial<
|
||||
|
||||
const logger = getVoidLogger();
|
||||
|
||||
const mockDir = createMockDirectory({ mockOsTmpDir: true });
|
||||
|
||||
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
|
||||
config: new ConfigReader({}),
|
||||
});
|
||||
@@ -69,18 +72,8 @@ const urlReaderFactory = (azureIntegration: AzureIntegrationConfigLike) => {
|
||||
);
|
||||
};
|
||||
|
||||
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
|
||||
|
||||
describe('AzureUrlReader', () => {
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
[tmpDir]: mockFs.directory(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
beforeEach(mockDir.clear);
|
||||
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
@@ -270,7 +263,7 @@ describe('AzureUrlReader', () => {
|
||||
'https://dev.azure.com/organization/project/_git/repository',
|
||||
);
|
||||
|
||||
const dir = await response.dir({ targetDir: tmpDir });
|
||||
const dir = await response.dir({ targetDir: mockDir.path });
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
|
||||
|
||||
@@ -19,18 +19,21 @@ import {
|
||||
BitbucketCloudIntegration,
|
||||
readBitbucketCloudIntegrationConfig,
|
||||
} from '@backstage/integration';
|
||||
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import {
|
||||
createMockDirectory,
|
||||
setupRequestMockHandlers,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { NotModifiedError } from '@backstage/errors';
|
||||
import { BitbucketCloudUrlReader } from './BitbucketCloudUrlReader';
|
||||
import { DefaultReadTreeResponseFactory } from './tree';
|
||||
import getRawBody from 'raw-body';
|
||||
|
||||
const mockDir = createMockDirectory({ mockOsTmpDir: true });
|
||||
|
||||
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
|
||||
config: new ConfigReader({}),
|
||||
});
|
||||
@@ -49,18 +52,8 @@ const reader = new BitbucketCloudUrlReader(
|
||||
{ treeResponseFactory },
|
||||
);
|
||||
|
||||
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
|
||||
|
||||
describe('BitbucketCloudUrlReader', () => {
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
[tmpDir]: mockFs.directory(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
beforeEach(mockDir.clear);
|
||||
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
@@ -316,7 +309,7 @@ describe('BitbucketCloudUrlReader', () => {
|
||||
'https://bitbucket.org/backstage/mock',
|
||||
);
|
||||
|
||||
const dir = await response.dir({ targetDir: tmpDir });
|
||||
const dir = await response.dir({ targetDir: mockDir.path });
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
|
||||
@@ -346,7 +339,7 @@ describe('BitbucketCloudUrlReader', () => {
|
||||
'https://bitbucket.org/backstage/mock/src/master/docs',
|
||||
);
|
||||
|
||||
const dir = await response.dir({ targetDir: tmpDir });
|
||||
const dir = await response.dir({ targetDir: mockDir.path });
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(dir, 'index.md'), 'utf8'),
|
||||
|
||||
@@ -19,17 +19,20 @@ import {
|
||||
BitbucketServerIntegration,
|
||||
readBitbucketServerIntegrationConfig,
|
||||
} from '@backstage/integration';
|
||||
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import {
|
||||
createMockDirectory,
|
||||
setupRequestMockHandlers,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { NotModifiedError } from '@backstage/errors';
|
||||
import { BitbucketServerUrlReader } from './BitbucketServerUrlReader';
|
||||
import { DefaultReadTreeResponseFactory } from './tree';
|
||||
|
||||
createMockDirectory({ mockOsTmpDir: true });
|
||||
|
||||
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
|
||||
config: new ConfigReader({}),
|
||||
});
|
||||
@@ -46,19 +49,7 @@ const reader = new BitbucketServerUrlReader(
|
||||
{ treeResponseFactory },
|
||||
);
|
||||
|
||||
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
|
||||
|
||||
describe('BitbucketServerUrlReader', () => {
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
[tmpDir]: mockFs.directory(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
|
||||
|
||||
@@ -19,12 +19,13 @@ import {
|
||||
BitbucketIntegration,
|
||||
readBitbucketIntegrationConfig,
|
||||
} from '@backstage/integration';
|
||||
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import {
|
||||
createMockDirectory,
|
||||
setupRequestMockHandlers,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { NotModifiedError } from '@backstage/errors';
|
||||
import { BitbucketUrlReader } from './BitbucketUrlReader';
|
||||
@@ -68,6 +69,10 @@ describe('BitbucketUrlReader.factory', () => {
|
||||
});
|
||||
|
||||
describe('BitbucketUrlReader', () => {
|
||||
const mockDir = createMockDirectory({ mockOsTmpDir: true });
|
||||
|
||||
beforeEach(mockDir.clear);
|
||||
|
||||
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
|
||||
config: new ConfigReader({}),
|
||||
});
|
||||
@@ -98,18 +103,6 @@ describe('BitbucketUrlReader', () => {
|
||||
{ treeResponseFactory },
|
||||
);
|
||||
|
||||
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
|
||||
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
[tmpDir]: mockFs.directory(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
|
||||
@@ -391,7 +384,7 @@ describe('BitbucketUrlReader', () => {
|
||||
'https://bitbucket.org/backstage/mock',
|
||||
);
|
||||
|
||||
const dir = await response.dir({ targetDir: tmpDir });
|
||||
const dir = await response.dir({ targetDir: mockDir.path });
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
|
||||
@@ -436,7 +429,7 @@ describe('BitbucketUrlReader', () => {
|
||||
'https://bitbucket.org/backstage/mock/src/master/docs',
|
||||
);
|
||||
|
||||
const dir = await response.dir({ targetDir: tmpDir });
|
||||
const dir = await response.dir({ targetDir: mockDir.path });
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(dir, 'index.md'), 'utf8'),
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import {
|
||||
createMockDirectory,
|
||||
setupRequestMockHandlers,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { NotModifiedError, NotFoundError } from '@backstage/errors';
|
||||
import {
|
||||
@@ -24,7 +27,6 @@ import {
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import mockFs from 'mock-fs';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { getVoidLogger } from '../logging';
|
||||
@@ -33,6 +35,8 @@ import { DefaultReadTreeResponseFactory } from './tree';
|
||||
import { GerritUrlReader } from './GerritUrlReader';
|
||||
import getRawBody from 'raw-body';
|
||||
|
||||
const mockDir = createMockDirectory({ mockOsTmpDir: true });
|
||||
|
||||
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
|
||||
config: new ConfigReader({}),
|
||||
});
|
||||
@@ -89,6 +93,8 @@ describe.skip('GerritUrlReader', () => {
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
|
||||
beforeEach(mockDir.clear);
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
@@ -249,14 +255,13 @@ describe.skip('GerritUrlReader', () => {
|
||||
path.resolve(__dirname, '__fixtures__/gerrit/gerrit-master-docs.tar.gz'),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
'/tmp/': mockFs.directory(),
|
||||
'/tmp/gerrit-clone-123abc/repo/mkdocs.yml': mkdocsContent,
|
||||
'/tmp/gerrit-clone-123abc/repo/docs/first.md': mdContent,
|
||||
beforeEach(async () => {
|
||||
mockDir.setContent({
|
||||
'repo/mkdocs.yml': mkdocsContent,
|
||||
'repo/docs/first.md': mdContent,
|
||||
});
|
||||
const spy = jest.spyOn(fs, 'mkdtemp');
|
||||
spy.mockImplementation(() => '/tmp/gerrit-clone-123abc');
|
||||
spy.mockImplementation(() => mockDir.path);
|
||||
|
||||
worker.use(
|
||||
rest.get(
|
||||
@@ -289,7 +294,6 @@ describe.skip('GerritUrlReader', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -20,12 +20,13 @@ import {
|
||||
GithubIntegration,
|
||||
readGithubIntegrationConfig,
|
||||
} from '@backstage/integration';
|
||||
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import {
|
||||
createMockDirectory,
|
||||
setupRequestMockHandlers,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { NotFoundError, NotModifiedError } from '@backstage/errors';
|
||||
import {
|
||||
@@ -37,6 +38,8 @@ import {
|
||||
} from './GithubUrlReader';
|
||||
import { DefaultReadTreeResponseFactory } from './tree';
|
||||
|
||||
const mockDir = createMockDirectory({ mockOsTmpDir: true });
|
||||
|
||||
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
|
||||
config: new ConfigReader({}),
|
||||
});
|
||||
@@ -69,21 +72,11 @@ const gheProcessor = new GithubUrlReader(
|
||||
{ treeResponseFactory, credentialsProvider: mockCredentialsProvider },
|
||||
);
|
||||
|
||||
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
|
||||
|
||||
describe('GithubUrlReader', () => {
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
[tmpDir]: mockFs.directory(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
beforeEach(mockDir.clear);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -420,7 +413,7 @@ describe('GithubUrlReader', () => {
|
||||
'https://github.com/backstage/mock',
|
||||
);
|
||||
|
||||
const dir = await response.dir({ targetDir: tmpDir });
|
||||
const dir = await response.dir({ targetDir: mockDir.path });
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
|
||||
@@ -501,7 +494,7 @@ describe('GithubUrlReader', () => {
|
||||
'https://github.com/backstage/mock/tree/main/docs',
|
||||
);
|
||||
|
||||
const dir = await response.dir({ targetDir: tmpDir });
|
||||
const dir = await response.dir({ targetDir: mockDir.path });
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(dir, 'index.md'), 'utf8'),
|
||||
|
||||
@@ -15,12 +15,13 @@
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import {
|
||||
createMockDirectory,
|
||||
setupRequestMockHandlers,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { getVoidLogger } from '../logging';
|
||||
import { GitlabUrlReader } from './GitlabUrlReader';
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
|
||||
const logger = getVoidLogger();
|
||||
|
||||
const mockDir = createMockDirectory({ mockOsTmpDir: true });
|
||||
|
||||
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
|
||||
config: new ConfigReader({}),
|
||||
});
|
||||
@@ -65,18 +68,8 @@ const hostedGitlabProcessor = new GitlabUrlReader(
|
||||
{ treeResponseFactory },
|
||||
);
|
||||
|
||||
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
|
||||
|
||||
describe('GitlabUrlReader', () => {
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
[tmpDir]: mockFs.directory(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
beforeEach(mockDir.clear);
|
||||
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
@@ -400,7 +393,7 @@ describe('GitlabUrlReader', () => {
|
||||
'https://gitlab.com/backstage/mock',
|
||||
);
|
||||
|
||||
const dir = await response.dir({ targetDir: tmpDir });
|
||||
const dir = await response.dir({ targetDir: mockDir.path });
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
|
||||
@@ -457,7 +450,7 @@ describe('GitlabUrlReader', () => {
|
||||
'https://gitlab.com/backstage/mock/tree/main/docs',
|
||||
);
|
||||
|
||||
const dir = await response.dir({ targetDir: tmpDir });
|
||||
const dir = await response.dir({ targetDir: mockDir.path });
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(dir, 'index.md'), 'utf8'),
|
||||
|
||||
@@ -15,41 +15,54 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import path, { resolve as resolvePath } from 'path';
|
||||
import path from 'path';
|
||||
import { FromReadableArrayOptions } from '../types';
|
||||
import { ReadableArrayResponse } from './ReadableArrayResponse';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
const path1 = '/file1.yaml';
|
||||
const name1 = 'file1.yaml';
|
||||
const file1 = fs.readFileSync(
|
||||
path.resolve(__filename, '../../__fixtures__/awsS3/awsS3-mock-object.yaml'),
|
||||
);
|
||||
|
||||
const path2 = '/file2.yaml';
|
||||
const name2 = 'file2.yaml';
|
||||
const file2 = fs.readFileSync(
|
||||
path.resolve(__filename, '../../__fixtures__/awsS3/awsS3-mock-object2.yaml'),
|
||||
);
|
||||
|
||||
describe('ReadableArrayResponse', () => {
|
||||
const sourceDir = createMockDirectory();
|
||||
const targetDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
[path1]: file1,
|
||||
[path2]: file2,
|
||||
'/tmp': mockFs.directory(),
|
||||
sourceDir.setContent({
|
||||
[name1]: file1,
|
||||
[name2]: file2,
|
||||
});
|
||||
targetDir.clear();
|
||||
});
|
||||
|
||||
const openStreams = new Array<fs.ReadStream>();
|
||||
function createReadStream(filePath: string) {
|
||||
const stream = fs.createReadStream(filePath);
|
||||
openStreams.push(stream);
|
||||
return stream;
|
||||
}
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
openStreams.forEach(stream => stream.destroy());
|
||||
openStreams.length = 0;
|
||||
});
|
||||
|
||||
const path1 = sourceDir.resolve(name1);
|
||||
const path2 = sourceDir.resolve(name2);
|
||||
|
||||
it('should read files', async () => {
|
||||
const arr: FromReadableArrayOptions = [
|
||||
{ data: fs.createReadStream(path1), path: path1 },
|
||||
{ data: fs.createReadStream(path2), path: path2 },
|
||||
{ data: createReadStream(path1), path: path1 },
|
||||
{ data: createReadStream(path2), path: path2 },
|
||||
];
|
||||
|
||||
const res = new ReadableArrayResponse(arr, '/tmp', 'etag');
|
||||
const res = new ReadableArrayResponse(arr, targetDir.path, 'etag');
|
||||
const files = await res.files();
|
||||
|
||||
expect(files).toEqual([
|
||||
@@ -57,26 +70,21 @@ describe('ReadableArrayResponse', () => {
|
||||
{ path: path2, content: expect.any(Function) },
|
||||
]);
|
||||
const contents = await Promise.all(files.map(f => f.content()));
|
||||
expect(contents.map(c => c.toString('utf8').trim())).toEqual([
|
||||
'site_name: Test',
|
||||
'site_name: Test2',
|
||||
]);
|
||||
expect(contents).toEqual([file1, file2]);
|
||||
});
|
||||
|
||||
it('should extract entire archive into directory', async () => {
|
||||
const arr: FromReadableArrayOptions = [
|
||||
{ data: fs.createReadStream(path1), path: path1 },
|
||||
{ data: fs.createReadStream(path2), path: path2 },
|
||||
{ data: createReadStream(path1), path: path1 },
|
||||
{ data: createReadStream(path2), path: path2 },
|
||||
];
|
||||
|
||||
const res = new ReadableArrayResponse(arr, '/tmp', 'etag');
|
||||
const res = new ReadableArrayResponse(arr, targetDir.path, 'etag');
|
||||
const dir = await res.dir();
|
||||
|
||||
await expect(
|
||||
fs.readFile(resolvePath(dir, 'file1.yaml'), 'utf8'),
|
||||
).resolves.toMatch(/site_name: Test/);
|
||||
await expect(
|
||||
fs.readFile(resolvePath(dir, 'file2.yaml'), 'utf8'),
|
||||
).resolves.toMatch(/site_name: Test2/);
|
||||
expect(targetDir.content({ path: dir })).toEqual({
|
||||
[name1]: file1.toString('utf8'),
|
||||
[name2]: file2.toString('utf8'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,30 +15,31 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { TarArchiveResponse } from './TarArchiveResponse';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
const archiveData = fs.readFileSync(
|
||||
resolvePath(__filename, '../../__fixtures__/mock-main.tar.gz'),
|
||||
);
|
||||
|
||||
describe('TarArchiveResponse', () => {
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
'/test-archive.tar.gz': archiveData,
|
||||
'/tmp': mockFs.directory(),
|
||||
});
|
||||
});
|
||||
const sourceDir = createMockDirectory();
|
||||
const targetDir = createMockDirectory();
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
beforeAll(() => {
|
||||
sourceDir.setContent({ 'test-archive.tar.gz': archiveData });
|
||||
});
|
||||
beforeEach(() => {
|
||||
targetDir.clear();
|
||||
});
|
||||
|
||||
it('should read files', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.tar.gz');
|
||||
const stream = fs.createReadStream(
|
||||
sourceDir.resolve('test-archive.tar.gz'),
|
||||
);
|
||||
|
||||
const res = new TarArchiveResponse(stream, '', '/tmp', 'etag');
|
||||
const res = new TarArchiveResponse(stream, '', targetDir.path, 'etag');
|
||||
const files = await res.files();
|
||||
|
||||
expect(files).toEqual([
|
||||
@@ -61,10 +62,16 @@ describe('TarArchiveResponse', () => {
|
||||
});
|
||||
|
||||
it('should read files with filter', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.tar.gz');
|
||||
const stream = fs.createReadStream(
|
||||
sourceDir.resolve('test-archive.tar.gz'),
|
||||
);
|
||||
|
||||
const res = new TarArchiveResponse(stream, '', '/tmp', 'etag', path =>
|
||||
path.endsWith('.yml'),
|
||||
const res = new TarArchiveResponse(
|
||||
stream,
|
||||
'',
|
||||
targetDir.path,
|
||||
'etag',
|
||||
path => path.endsWith('.yml'),
|
||||
);
|
||||
const files = await res.files();
|
||||
|
||||
@@ -80,16 +87,18 @@ describe('TarArchiveResponse', () => {
|
||||
});
|
||||
|
||||
it('should read as archive and files', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.tar.gz');
|
||||
const stream = fs.createReadStream(
|
||||
sourceDir.resolve('test-archive.tar.gz'),
|
||||
);
|
||||
|
||||
const res = new TarArchiveResponse(stream, '', '/tmp', 'etag');
|
||||
const res = new TarArchiveResponse(stream, '', targetDir.path, 'etag');
|
||||
const buffer = await res.archive();
|
||||
|
||||
await expect(res.archive()).rejects.toThrow(
|
||||
'Response has already been read',
|
||||
);
|
||||
|
||||
const res2 = new TarArchiveResponse(buffer, '', '/tmp', 'etag');
|
||||
const res2 = new TarArchiveResponse(buffer, '', targetDir.path, 'etag');
|
||||
const files = await res2.files();
|
||||
|
||||
expect(files).toEqual([
|
||||
@@ -112,9 +121,11 @@ describe('TarArchiveResponse', () => {
|
||||
});
|
||||
|
||||
it('should extract entire archive into directory', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.tar.gz');
|
||||
const stream = fs.createReadStream(
|
||||
sourceDir.resolve('test-archive.tar.gz'),
|
||||
);
|
||||
|
||||
const res = new TarArchiveResponse(stream, '', '/tmp', 'etag');
|
||||
const res = new TarArchiveResponse(stream, '', targetDir.path, 'etag');
|
||||
const dir = await res.dir();
|
||||
await expect(
|
||||
fs.readFile(resolvePath(dir, 'mkdocs.yml'), 'utf8'),
|
||||
@@ -125,67 +136,91 @@ describe('TarArchiveResponse', () => {
|
||||
});
|
||||
|
||||
it('should extract archive into directory with a subpath', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.tar.gz');
|
||||
const stream = fs.createReadStream(
|
||||
sourceDir.resolve('test-archive.tar.gz'),
|
||||
);
|
||||
|
||||
const res = new TarArchiveResponse(stream, 'docs', '/tmp', 'etag');
|
||||
const res = new TarArchiveResponse(stream, 'docs', targetDir.path, 'etag');
|
||||
const dir = await res.dir();
|
||||
|
||||
expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/);
|
||||
await expect(
|
||||
fs.readFile(resolvePath(dir, 'index.md'), 'utf8'),
|
||||
).resolves.toBe('# Test\n');
|
||||
expect(targetDir.content({ path: dir })).toEqual({
|
||||
'index.md': '# Test\n',
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract archive into directory with a subpath and filter', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.tar.gz');
|
||||
|
||||
const res = new TarArchiveResponse(stream, '', '/tmp', 'etag', path =>
|
||||
path.endsWith('.yml'),
|
||||
const stream = fs.createReadStream(
|
||||
sourceDir.resolve('test-archive.tar.gz'),
|
||||
);
|
||||
const dir = await res.dir({ targetDir: '/tmp' });
|
||||
|
||||
expect(dir).toBe('/tmp');
|
||||
await expect(fs.pathExists(resolvePath(dir, 'mkdocs.yml'))).resolves.toBe(
|
||||
true,
|
||||
const res = new TarArchiveResponse(
|
||||
stream,
|
||||
'',
|
||||
targetDir.path,
|
||||
'etag',
|
||||
path => path.endsWith('.yml'),
|
||||
);
|
||||
await expect(
|
||||
fs.pathExists(resolvePath(dir, 'docs/index.md')),
|
||||
).resolves.toBe(false);
|
||||
|
||||
targetDir.addContent({ sub: {} });
|
||||
const dir = await res.dir({ targetDir: targetDir.resolve('sub') });
|
||||
|
||||
expect(dir).toBe(targetDir.resolve('sub'));
|
||||
expect(targetDir.content()).toEqual({
|
||||
sub: {
|
||||
'mkdocs.yml': 'site_name: Test\n',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should leave temporary directories in place in the case of an error', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.tar.gz');
|
||||
it('should clean up temporary directories in place in the case of an error', async () => {
|
||||
const stream = fs.createReadStream(
|
||||
sourceDir.resolve('test-archive.tar.gz'),
|
||||
);
|
||||
|
||||
const res = new TarArchiveResponse(stream, '', '/tmp', 'etag', () => {
|
||||
throw new Error('NOPE');
|
||||
});
|
||||
const res = new TarArchiveResponse(
|
||||
stream,
|
||||
'',
|
||||
targetDir.path,
|
||||
'etag',
|
||||
() => {
|
||||
throw new Error('NOPE');
|
||||
},
|
||||
);
|
||||
|
||||
const tmpDir = await fs.mkdtemp('/tmp/test');
|
||||
// selects the wrong overload by default
|
||||
const mkdtemp = jest.spyOn(fs, 'mkdtemp') as unknown as jest.SpyInstance<
|
||||
Promise<string>,
|
||||
[]
|
||||
>;
|
||||
mkdtemp.mockResolvedValue(tmpDir);
|
||||
targetDir.addContent({ sub: {} });
|
||||
const sub = targetDir.resolve('sub');
|
||||
|
||||
await expect(fs.pathExists(tmpDir)).resolves.toBe(true);
|
||||
const mkdtemp = jest
|
||||
.spyOn(fs, 'mkdtemp')
|
||||
.mockImplementation(async () => sub);
|
||||
|
||||
await expect(fs.pathExists(sub)).resolves.toBe(true);
|
||||
await expect(res.dir()).rejects.toThrow('NOPE');
|
||||
await expect(fs.pathExists(tmpDir)).resolves.toBe(false);
|
||||
await expect(fs.pathExists(sub)).resolves.toBe(false);
|
||||
|
||||
mkdtemp.mockRestore();
|
||||
});
|
||||
|
||||
it('should leave directory in place if provided in the case of an error', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.tar.gz');
|
||||
const stream = fs.createReadStream(
|
||||
sourceDir.resolve('test-archive.tar.gz'),
|
||||
);
|
||||
|
||||
const res = new TarArchiveResponse(stream, '', '/tmp', 'etag', () => {
|
||||
throw new Error('NOPE');
|
||||
});
|
||||
const res = new TarArchiveResponse(
|
||||
stream,
|
||||
'',
|
||||
targetDir.path,
|
||||
'etag',
|
||||
() => {
|
||||
throw new Error('NOPE');
|
||||
},
|
||||
);
|
||||
|
||||
const tmpDir = await fs.mkdtemp('/tmp/test');
|
||||
targetDir.addContent({ sub: {} });
|
||||
const sub = targetDir.resolve('sub');
|
||||
|
||||
await expect(fs.pathExists(tmpDir)).resolves.toBe(true);
|
||||
await expect(res.dir({ targetDir: tmpDir })).rejects.toThrow('NOPE');
|
||||
await expect(fs.pathExists(tmpDir)).resolves.toBe(true);
|
||||
await expect(fs.pathExists(sub)).resolves.toBe(true);
|
||||
await expect(res.dir({ targetDir: sub })).rejects.toThrow('NOPE');
|
||||
await expect(fs.pathExists(sub)).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { Readable } from 'stream';
|
||||
import { create as createArchive } from 'archiver';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { ZipArchiveResponse } from './ZipArchiveResponse';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
const archiveData = fs.readFileSync(
|
||||
resolvePath(__filename, '../../__fixtures__/mock-main.zip'),
|
||||
@@ -35,24 +35,36 @@ const archiveWithMaliciousEntry = fs.readFileSync(
|
||||
);
|
||||
|
||||
describe('ZipArchiveResponse', () => {
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
'/test-archive.zip': archiveData,
|
||||
'/test-archive-with-extra-root-dir.zip': archiveDataWithExtraDir,
|
||||
'/test-archive-corrupted.zip': archiveDataCorrupted,
|
||||
'/test-archive-malicious.zip': archiveWithMaliciousEntry,
|
||||
'/tmp': mockFs.directory(),
|
||||
const sourceDir = createMockDirectory();
|
||||
const targetDir = createMockDirectory();
|
||||
|
||||
beforeAll(() => {
|
||||
sourceDir.setContent({
|
||||
'test-archive.zip': archiveData,
|
||||
'test-archive-with-extra-root-dir.zip': archiveDataWithExtraDir,
|
||||
'test-archive-corrupted.zip': archiveDataCorrupted,
|
||||
'test-archive-malicious.zip': archiveWithMaliciousEntry,
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
targetDir.clear();
|
||||
});
|
||||
|
||||
const openStreams = new Array<fs.ReadStream>();
|
||||
function createReadStream(filePath: string) {
|
||||
const stream = fs.createReadStream(filePath);
|
||||
openStreams.push(stream);
|
||||
return stream;
|
||||
}
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
openStreams.forEach(stream => stream.destroy());
|
||||
openStreams.length = 0;
|
||||
});
|
||||
|
||||
it('should read files', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.zip');
|
||||
const stream = createReadStream(sourceDir.resolve('test-archive.zip'));
|
||||
|
||||
const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag');
|
||||
const res = new ZipArchiveResponse(stream, '', targetDir.path, 'etag');
|
||||
const files = await res.files();
|
||||
|
||||
expect(files).toEqual([
|
||||
@@ -76,10 +88,14 @@ describe('ZipArchiveResponse', () => {
|
||||
});
|
||||
|
||||
it('should read files with filter', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.zip');
|
||||
const stream = createReadStream(sourceDir.resolve('test-archive.zip'));
|
||||
|
||||
const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag', path =>
|
||||
path.endsWith('.yml'),
|
||||
const res = new ZipArchiveResponse(
|
||||
stream,
|
||||
'',
|
||||
targetDir.path,
|
||||
'etag',
|
||||
path => path.endsWith('.yml'),
|
||||
);
|
||||
const files = await res.files();
|
||||
|
||||
@@ -95,16 +111,16 @@ describe('ZipArchiveResponse', () => {
|
||||
});
|
||||
|
||||
it('should read as archive and files', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.zip');
|
||||
const stream = createReadStream(sourceDir.resolve('test-archive.zip'));
|
||||
|
||||
const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag');
|
||||
const res = new ZipArchiveResponse(stream, '', targetDir.path, 'etag');
|
||||
const buffer = await res.archive();
|
||||
|
||||
await expect(res.archive()).rejects.toThrow(
|
||||
'Response has already been read',
|
||||
);
|
||||
|
||||
const res2 = new ZipArchiveResponse(buffer, '', '/tmp', 'etag');
|
||||
const res2 = new ZipArchiveResponse(buffer, '', targetDir.path, 'etag');
|
||||
const files = await res2.files();
|
||||
|
||||
expect(files).toEqual([
|
||||
@@ -127,9 +143,9 @@ describe('ZipArchiveResponse', () => {
|
||||
});
|
||||
|
||||
it('should extract entire archive into directory', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.zip');
|
||||
const stream = createReadStream(sourceDir.resolve('test-archive.zip'));
|
||||
|
||||
const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag');
|
||||
const res = new ZipArchiveResponse(stream, '', targetDir.path, 'etag');
|
||||
const dir = await res.dir();
|
||||
|
||||
await expect(
|
||||
@@ -141,38 +157,45 @@ describe('ZipArchiveResponse', () => {
|
||||
});
|
||||
|
||||
it('should extract archive into directory with a subpath', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.zip');
|
||||
const stream = createReadStream(sourceDir.resolve('test-archive.zip'));
|
||||
|
||||
const res = new ZipArchiveResponse(stream, 'docs/', targetDir.path, 'etag');
|
||||
|
||||
const res = new ZipArchiveResponse(stream, 'docs/', '/tmp', 'etag');
|
||||
const dir = await res.dir();
|
||||
expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/);
|
||||
await expect(
|
||||
fs.readFile(resolvePath(dir, 'index.md'), 'utf8'),
|
||||
).resolves.toBe('# Test\n');
|
||||
expect(targetDir.content({ path: dir })).toEqual({
|
||||
'index.md': '# Test\n',
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract archive into directory with a subpath and filter', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.zip');
|
||||
const stream = createReadStream(sourceDir.resolve('test-archive.zip'));
|
||||
|
||||
const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag', path =>
|
||||
path.endsWith('.yml'),
|
||||
const res = new ZipArchiveResponse(
|
||||
stream,
|
||||
'',
|
||||
targetDir.path,
|
||||
'etag',
|
||||
path => path.endsWith('.yml'),
|
||||
);
|
||||
const dir = await res.dir({ targetDir: '/tmp' });
|
||||
|
||||
expect(dir).toBe('/tmp');
|
||||
await expect(fs.pathExists(resolvePath(dir, 'mkdocs.yml'))).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
await expect(
|
||||
fs.pathExists(resolvePath(dir, 'docs/index.md')),
|
||||
).resolves.toBe(false);
|
||||
targetDir.addContent({ sub: {} });
|
||||
const sub = targetDir.resolve('sub');
|
||||
const dir = await res.dir({ targetDir: sub });
|
||||
|
||||
expect(dir).toBe(sub);
|
||||
|
||||
expect(targetDir.content()).toEqual({
|
||||
sub: {
|
||||
'mkdocs.yml': 'site_name: Test\n',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract a large archive', async () => {
|
||||
const fileCount = 10;
|
||||
const fileSize = 1000 * 1000;
|
||||
const filePath = await new Promise<string>((resolve, reject) => {
|
||||
const outFile = '/large-archive.zip';
|
||||
const outFile = targetDir.resolve('large-archive.zip');
|
||||
const archive = createArchive('zip');
|
||||
|
||||
archive.on('error', reject);
|
||||
@@ -193,14 +216,16 @@ describe('ZipArchiveResponse', () => {
|
||||
archive.finalize();
|
||||
});
|
||||
|
||||
const stream = fs.createReadStream(filePath);
|
||||
const stream = createReadStream(filePath);
|
||||
|
||||
const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag');
|
||||
const res = new ZipArchiveResponse(stream, '', targetDir.path, 'etag');
|
||||
|
||||
targetDir.addContent({ sub: {} });
|
||||
const sub = targetDir.resolve('sub');
|
||||
const dir = await res.dir({
|
||||
targetDir: '/out',
|
||||
targetDir: sub,
|
||||
});
|
||||
|
||||
expect(dir).toBe('/out');
|
||||
const files = await fs.readdir(dir);
|
||||
expect(files).toHaveLength(fileCount);
|
||||
|
||||
@@ -211,9 +236,11 @@ describe('ZipArchiveResponse', () => {
|
||||
});
|
||||
|
||||
it('should throw on invalid archive', async () => {
|
||||
const stream = fs.createReadStream('/test-archive-corrupted.zip');
|
||||
const stream = createReadStream(
|
||||
sourceDir.resolve('test-archive-corrupted.zip'),
|
||||
);
|
||||
|
||||
const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag');
|
||||
const res = new ZipArchiveResponse(stream, '', targetDir.path, 'etag');
|
||||
const filesPromise = res.files();
|
||||
|
||||
await expect(filesPromise).rejects.toThrow(
|
||||
@@ -222,18 +249,22 @@ describe('ZipArchiveResponse', () => {
|
||||
});
|
||||
|
||||
it('should throw on entries with a path outside the destination dir', async () => {
|
||||
const stream = fs.createReadStream('/test-archive-malicious.zip');
|
||||
const stream = createReadStream(
|
||||
sourceDir.resolve('test-archive-malicious.zip'),
|
||||
);
|
||||
|
||||
const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag');
|
||||
const res = new ZipArchiveResponse(stream, '', targetDir.path, 'etag');
|
||||
await expect(res.files()).rejects.toThrow(
|
||||
'invalid relative path: ../side.txt',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on entries that attempt to write outside destination dir', async () => {
|
||||
const stream = fs.createReadStream('/test-archive-malicious.zip');
|
||||
const stream = createReadStream(
|
||||
sourceDir.resolve('test-archive-malicious.zip'),
|
||||
);
|
||||
|
||||
const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag');
|
||||
const res = new ZipArchiveResponse(stream, '', targetDir.path, 'etag');
|
||||
await expect(res.dir()).rejects.toThrow(
|
||||
'invalid relative path: ../side.txt',
|
||||
);
|
||||
|
||||
@@ -93,9 +93,13 @@ export class ZipArchiveResponse implements ReadTreeResponse {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
writeStream.on('error', reject);
|
||||
writeStream.on('finish', () =>
|
||||
resolve({ fileName: tmpFile, cleanup: () => fs.remove(tmpFile) }),
|
||||
);
|
||||
writeStream.on('finish', () => {
|
||||
writeStream.end();
|
||||
resolve({
|
||||
fileName: tmpFile,
|
||||
cleanup: () => fs.rm(tmpDir, { recursive: true }),
|
||||
});
|
||||
});
|
||||
stream.pipe(writeStream);
|
||||
});
|
||||
}
|
||||
@@ -152,7 +156,7 @@ export class ZipArchiveResponse implements ReadTreeResponse {
|
||||
});
|
||||
});
|
||||
|
||||
temporary.cleanup();
|
||||
await temporary.cleanup();
|
||||
|
||||
return files;
|
||||
}
|
||||
@@ -175,7 +179,7 @@ export class ZipArchiveResponse implements ReadTreeResponse {
|
||||
|
||||
archive.finalize();
|
||||
|
||||
temporary.cleanup();
|
||||
await temporary.cleanup();
|
||||
|
||||
return archive;
|
||||
}
|
||||
@@ -204,7 +208,7 @@ export class ZipArchiveResponse implements ReadTreeResponse {
|
||||
});
|
||||
});
|
||||
|
||||
temporary.cleanup();
|
||||
await temporary.cleanup();
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -14,27 +14,24 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import Docker from 'dockerode';
|
||||
import mockFs from 'mock-fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import Stream, { PassThrough } from 'stream';
|
||||
import { ContainerRunner } from './ContainerRunner';
|
||||
import { DockerContainerRunner, UserOptions } from './DockerContainerRunner';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
const mockDocker = new Docker() as jest.Mocked<Docker>;
|
||||
const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
|
||||
|
||||
describe('DockerContainerRunner', () => {
|
||||
let containerTaskApi: ContainerRunner;
|
||||
|
||||
const inputDir = createMockDirectory();
|
||||
const outputDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
[rootDir]: {
|
||||
input: mockFs.directory(),
|
||||
output: mockFs.directory(),
|
||||
},
|
||||
});
|
||||
inputDir.clear();
|
||||
outputDir.clear();
|
||||
|
||||
jest.spyOn(mockDocker, 'pull').mockImplementation((async (
|
||||
_image: string,
|
||||
@@ -59,16 +56,15 @@ describe('DockerContainerRunner', () => {
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
const imageName = 'dockerOrg/image';
|
||||
const args = ['bash', '-c', 'echo test'];
|
||||
const mountDirs = {
|
||||
[path.join(rootDir, 'input')]: '/input',
|
||||
[path.join(rootDir, 'output')]: '/output',
|
||||
[inputDir.path]: '/input',
|
||||
[outputDir.path]: '/output',
|
||||
};
|
||||
const workingDir = path.join(rootDir, 'input');
|
||||
const workingDir = inputDir.path;
|
||||
const envVars = { HOME: '/tmp', LOG_LEVEL: 'debug' };
|
||||
const envVarsArray = ['HOME=/tmp', 'LOG_LEVEL=debug'];
|
||||
|
||||
@@ -117,8 +113,8 @@ describe('DockerContainerRunner', () => {
|
||||
HostConfig: {
|
||||
AutoRemove: true,
|
||||
Binds: expect.arrayContaining([
|
||||
`${path.join(rootDir, 'input')}:/input`,
|
||||
`${path.join(rootDir, 'output')}:/output`,
|
||||
`${await fs.realpath(inputDir.path)}:/input`,
|
||||
`${await fs.realpath(outputDir.path)}:/output`,
|
||||
]),
|
||||
},
|
||||
Volumes: {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
```ts
|
||||
/// <reference types="jest" />
|
||||
/// <reference types="node" />
|
||||
|
||||
import { Backend } from '@backstage/backend-app-api';
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
@@ -30,9 +31,44 @@ import { ServiceRef } from '@backstage/backend-plugin-api';
|
||||
import { TokenManagerService } from '@backstage/backend-plugin-api';
|
||||
import { UrlReaderService } from '@backstage/backend-plugin-api';
|
||||
|
||||
// @public
|
||||
export function createMockDirectory(
|
||||
options?: MockDirectoryOptions,
|
||||
): MockDirectory;
|
||||
|
||||
// @public (undocumented)
|
||||
export function isDockerDisabledForTests(): boolean;
|
||||
|
||||
// @public
|
||||
export interface MockDirectory {
|
||||
addContent(root: MockDirectoryContent): void;
|
||||
clear(): void;
|
||||
content(
|
||||
options?: MockDirectoryContentOptions,
|
||||
): MockDirectoryContent | undefined;
|
||||
readonly path: string;
|
||||
remove(): void;
|
||||
resolve(...paths: string[]): string;
|
||||
setContent(root: MockDirectoryContent): void;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type MockDirectoryContent = {
|
||||
[name in string]: MockDirectoryContent | string | Buffer;
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface MockDirectoryContentOptions {
|
||||
path?: string;
|
||||
shouldReadAsText?: boolean | ((path: string, buffer: Buffer) => boolean);
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface MockDirectoryOptions {
|
||||
content?: MockDirectoryContent;
|
||||
mockOsTmpDir?: boolean;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export namespace mockServices {
|
||||
// (undocumented)
|
||||
|
||||
@@ -46,15 +46,18 @@
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"better-sqlite3": "^8.0.0",
|
||||
"express": "^4.17.1",
|
||||
"fs-extra": "^10.0.1",
|
||||
"knex": "^2.0.0",
|
||||
"msw": "^1.0.0",
|
||||
"mysql2": "^2.2.5",
|
||||
"pg": "^8.3.0",
|
||||
"testcontainers": "^8.1.2",
|
||||
"textextensions": "^5.16.0",
|
||||
"uuid": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/*
|
||||
* Copyright 2023 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 fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import { join as joinPath, relative as relativePath } from 'path';
|
||||
import { createMockDirectory, MockDirectory } from './MockDirectory';
|
||||
|
||||
describe('createMockDirectory', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
beforeEach(mockDir.clear);
|
||||
|
||||
it('should resolve paths', () => {
|
||||
expect(mockDir.path).toEqual(expect.any(String));
|
||||
expect(relativePath(mockDir.path, mockDir.resolve('a'))).toBe('a');
|
||||
expect(relativePath(mockDir.path, mockDir.resolve('a/b/c'))).toBe(
|
||||
joinPath('a', 'b', 'c'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove itself', async () => {
|
||||
await expect(fs.pathExists(mockDir.path)).resolves.toBe(true);
|
||||
mockDir.remove();
|
||||
await expect(fs.pathExists(mockDir.path)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should populate a directory with text files', () => {
|
||||
mockDir.setContent({
|
||||
'a.txt': 'a',
|
||||
'a/b.txt': 'b',
|
||||
'a/b/c.txt': 'c',
|
||||
'a/b/d.txt': 'd',
|
||||
});
|
||||
|
||||
expect(mockDir.content()).toEqual({
|
||||
'a.txt': 'a',
|
||||
a: {
|
||||
'b.txt': 'b',
|
||||
b: {
|
||||
'c.txt': 'c',
|
||||
'd.txt': 'd',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should mix text and binary files', () => {
|
||||
mockDir.setContent({
|
||||
'a.txt': 'a',
|
||||
'a/b.txt': 'b',
|
||||
'a/b/c.bin': Buffer.from([0xc]),
|
||||
'a/b/d.bin': Buffer.from([0xd]),
|
||||
});
|
||||
|
||||
expect(mockDir.content()).toEqual({
|
||||
'a.txt': 'a',
|
||||
a: {
|
||||
'b.txt': 'b',
|
||||
b: {
|
||||
'c.bin': Buffer.from([0xc]),
|
||||
'd.bin': Buffer.from([0xd]),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to add content', () => {
|
||||
mockDir.setContent({
|
||||
'a.txt': 'a',
|
||||
b: {},
|
||||
});
|
||||
|
||||
expect(mockDir.content()).toEqual({
|
||||
'a.txt': 'a',
|
||||
b: {},
|
||||
});
|
||||
|
||||
mockDir.addContent({
|
||||
'b.txt': 'b',
|
||||
b: {
|
||||
'c.txt': 'c',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockDir.content()).toEqual({
|
||||
'a.txt': 'a',
|
||||
'b.txt': 'b',
|
||||
b: {
|
||||
'c.txt': 'c',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace existing files', () => {
|
||||
mockDir.setContent({
|
||||
'a.txt': 'a',
|
||||
});
|
||||
|
||||
mockDir.addContent({
|
||||
'a.txt': 'a2',
|
||||
});
|
||||
|
||||
expect(mockDir.content()).toEqual({
|
||||
'a.txt': 'a2',
|
||||
});
|
||||
});
|
||||
|
||||
it('should read content from sub dirs', () => {
|
||||
mockDir.setContent({
|
||||
'a.txt': 'a',
|
||||
'b/b.txt': 'b',
|
||||
'b/c/c.txt': 'c',
|
||||
});
|
||||
|
||||
const expected = {
|
||||
'a.txt': 'a',
|
||||
b: {
|
||||
'b.txt': 'b',
|
||||
c: {
|
||||
'c.txt': 'c',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(mockDir.content()).toEqual(expected);
|
||||
expect(mockDir.content({ path: mockDir.path })).toEqual(expected);
|
||||
expect(mockDir.content({ path: mockDir.resolve('.') })).toEqual(expected);
|
||||
expect(mockDir.content({ path: 'b' })).toEqual(expected.b);
|
||||
expect(mockDir.content({ path: './b' })).toEqual(expected.b);
|
||||
expect(mockDir.content({ path: mockDir.resolve('b') })).toEqual(expected.b);
|
||||
expect(mockDir.content({ path: 'b/c' })).toEqual(expected.b.c);
|
||||
expect(mockDir.content({ path: './b/c' })).toEqual(expected.b.c);
|
||||
expect(mockDir.content({ path: mockDir.resolve('b/c') })).toEqual(
|
||||
expected.b.c,
|
||||
);
|
||||
expect(mockDir.content({ path: mockDir.resolve('b', 'c') })).toEqual(
|
||||
expected.b.c,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow text reading to be configured', () => {
|
||||
const text = 'a';
|
||||
const binary = Buffer.from('a', 'utf8');
|
||||
|
||||
mockDir.setContent({
|
||||
a: binary,
|
||||
'a.txt': text,
|
||||
'a.bin': binary,
|
||||
});
|
||||
|
||||
expect(mockDir.content()).toEqual({
|
||||
a: binary,
|
||||
'a.txt': text,
|
||||
'a.bin': binary,
|
||||
});
|
||||
|
||||
expect(mockDir.content({ shouldReadAsText: false })).toEqual({
|
||||
a: binary,
|
||||
'a.txt': binary,
|
||||
'a.bin': binary,
|
||||
});
|
||||
|
||||
expect(mockDir.content({ shouldReadAsText: true })).toEqual({
|
||||
a: text,
|
||||
'a.txt': text,
|
||||
'a.bin': text,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockDir.content({ shouldReadAsText: path => path.length > 3 }),
|
||||
).toEqual({
|
||||
a: binary,
|
||||
'a.txt': text,
|
||||
'a.bin': text,
|
||||
});
|
||||
});
|
||||
|
||||
it('should provide a posix path to shouldReadAsText', () => {
|
||||
const shouldReadAsText = jest.fn().mockReturnValue(true);
|
||||
|
||||
mockDir.setContent({ 'a/b/c': 'c' });
|
||||
|
||||
expect(mockDir.content({ shouldReadAsText })).toEqual({
|
||||
a: { b: { c: 'c' } },
|
||||
});
|
||||
expect(shouldReadAsText).toHaveBeenCalledWith(
|
||||
'a/b/c',
|
||||
Buffer.from('c', 'utf8'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not override directories', () => {
|
||||
mockDir.setContent({
|
||||
'a.txt': 'a',
|
||||
b: {},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
mockDir.addContent({
|
||||
'a.txt': {},
|
||||
}),
|
||||
).toThrow('EEXIST');
|
||||
|
||||
expect(() =>
|
||||
mockDir.addContent({
|
||||
b: 'b',
|
||||
}),
|
||||
).toThrow('EISDIR');
|
||||
});
|
||||
|
||||
it('examples should work', () => {
|
||||
mockDir.setContent({
|
||||
'test.txt': 'content',
|
||||
'sub-dir': {
|
||||
'file.txt': 'content',
|
||||
'nested-dir/file.txt': 'content',
|
||||
},
|
||||
'empty-dir': {},
|
||||
'binary-file': Buffer.from([0, 1, 2]),
|
||||
});
|
||||
|
||||
mockDir.addContent({
|
||||
'test.txt': 'content',
|
||||
'sub-dir': {
|
||||
'file.txt': 'content',
|
||||
'nested-dir/file.txt': 'content',
|
||||
},
|
||||
'empty-dir': {},
|
||||
'binary-file': Buffer.from([0, 1, 2]),
|
||||
});
|
||||
|
||||
expect(mockDir.content()).toEqual({
|
||||
'test.txt': 'content',
|
||||
'sub-dir': {
|
||||
'file.txt': 'content',
|
||||
'nested-dir': {
|
||||
'file.txt': 'content',
|
||||
},
|
||||
},
|
||||
'empty-dir': {},
|
||||
'binary-file': Buffer.from([0, 1, 2]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject non-child paths', () => {
|
||||
const path = mockDir.resolve('/root/a.txt');
|
||||
expect(() => mockDir.setContent({ '/root/a.txt': 'a' })).toThrow(
|
||||
`Provided path must resolve to a child path of the mock directory, got '${path}'`,
|
||||
);
|
||||
expect(() => mockDir.addContent({ '/root/a.txt': 'a' })).toThrow(
|
||||
`Provided path must resolve to a child path of the mock directory, got '${path}'`,
|
||||
);
|
||||
expect(() => mockDir.content({ path: '/root/a.txt' })).toThrow(
|
||||
`Provided path must resolve to a child path of the mock directory, got '${path}'`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('tmpdir mock', () => {
|
||||
let tmpDirMock: MockDirectory;
|
||||
|
||||
describe('inner', () => {
|
||||
tmpDirMock = createMockDirectory({ mockOsTmpDir: true });
|
||||
|
||||
it('should mock os.tmpdir()', () => {
|
||||
expect(os.tmpdir()).toBe(tmpDirMock.path);
|
||||
});
|
||||
|
||||
it('should refuce to mock os.tmpdir() again', () => {
|
||||
expect(() => createMockDirectory({ mockOsTmpDir: true })).toThrow(
|
||||
'Cannot mock os.tmpdir() when it has already been mocked',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should restore os.tmpdir()', () => {
|
||||
expect(os.tmpdir()).not.toBe(tmpDirMock.path);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
/*
|
||||
* Copyright 2023 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 os from 'os';
|
||||
import { isChildPath } from '@backstage/backend-common';
|
||||
import fs from 'fs-extra';
|
||||
import textextensions from 'textextensions';
|
||||
import {
|
||||
dirname,
|
||||
extname,
|
||||
join as joinPath,
|
||||
resolve as resolvePath,
|
||||
relative as relativePath,
|
||||
win32,
|
||||
posix,
|
||||
} from 'path';
|
||||
|
||||
const tmpdirMarker = Symbol('os-tmpdir-mock');
|
||||
|
||||
/**
|
||||
* The content of a mock directory represented by a nested object structure.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* When used as input, the keys may contain forward slashes to indicate nested directories.
|
||||
* Then returned as output, each directory will always be represented as a separate object.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* 'test.txt': 'content',
|
||||
* 'sub-dir': {
|
||||
* 'file.txt': 'content',
|
||||
* 'nested-dir/file.txt': 'content',
|
||||
* },
|
||||
* 'empty-dir': {},
|
||||
* 'binary-file': Buffer.from([0, 1, 2]),
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type MockDirectoryContent = {
|
||||
[name in string]: MockDirectoryContent | string | Buffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for {@link MockDirectory.content}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface MockDirectoryContentOptions {
|
||||
/**
|
||||
* The path to read content from. Defaults to the root of the mock directory.
|
||||
*
|
||||
* An absolute path can also be provided, as long as it is a child path of the mock directory.
|
||||
*/
|
||||
path?: string;
|
||||
|
||||
/**
|
||||
* Whether or not to return files as text rather than buffers.
|
||||
*
|
||||
* Defaults to checking the file extension against a list of known text extensions.
|
||||
*/
|
||||
shouldReadAsText?: boolean | ((path: string, buffer: Buffer) => boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* A utility for creating a mock directory that is automatically cleaned up.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface MockDirectory {
|
||||
/**
|
||||
* The path to the root of the mock directory
|
||||
*/
|
||||
readonly path: string;
|
||||
|
||||
/**
|
||||
* Resolves a path relative to the root of the mock directory.
|
||||
*/
|
||||
resolve(...paths: string[]): string;
|
||||
|
||||
/**
|
||||
* Sets the content of the mock directory. This will remove any existing content.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* mockDir.setContent({
|
||||
* 'test.txt': 'content',
|
||||
* 'sub-dir': {
|
||||
* 'file.txt': 'content',
|
||||
* 'nested-dir/file.txt': 'content',
|
||||
* },
|
||||
* 'empty-dir': {},
|
||||
* 'binary-file': Buffer.from([0, 1, 2]),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
setContent(root: MockDirectoryContent): void;
|
||||
|
||||
/**
|
||||
* Adds content of the mock directory. This will overwrite existing files.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* mockDir.addContent({
|
||||
* 'test.txt': 'content',
|
||||
* 'sub-dir': {
|
||||
* 'file.txt': 'content',
|
||||
* 'nested-dir/file.txt': 'content',
|
||||
* },
|
||||
* 'empty-dir': {},
|
||||
* 'binary-file': Buffer.from([0, 1, 2]),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
addContent(root: MockDirectoryContent): void;
|
||||
|
||||
/**
|
||||
* Reads the content of the mock directory.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Text files will be returned as strings, while binary files will be returned as buffers.
|
||||
* By default the file extension is used to determine whether a file should be read as text.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expect(mockDir.content()).toEqual({
|
||||
* 'test.txt': 'content',
|
||||
* 'sub-dir': {
|
||||
* 'file.txt': 'content',
|
||||
* 'nested-dir': {
|
||||
* 'file.txt': 'content',
|
||||
* },
|
||||
* },
|
||||
* 'empty-dir': {},
|
||||
* 'binary-file': Buffer.from([0, 1, 2]),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
content(
|
||||
options?: MockDirectoryContentOptions,
|
||||
): MockDirectoryContent | undefined;
|
||||
|
||||
/**
|
||||
* Clears the content of the mock directory, ensuring that the directory itself exists.
|
||||
*/
|
||||
clear(): void;
|
||||
|
||||
/**
|
||||
* Removes the mock directory and all its contents.
|
||||
*/
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
type MockEntry =
|
||||
| {
|
||||
type: 'file';
|
||||
path: string;
|
||||
content: Buffer;
|
||||
}
|
||||
| {
|
||||
type: 'dir';
|
||||
path: string;
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
class MockDirectoryImpl {
|
||||
readonly #root: string;
|
||||
|
||||
constructor(root: string) {
|
||||
this.#root = root;
|
||||
}
|
||||
|
||||
get path(): string {
|
||||
return this.#root;
|
||||
}
|
||||
|
||||
resolve(...paths: string[]): string {
|
||||
return resolvePath(this.#root, ...paths);
|
||||
}
|
||||
|
||||
setContent(root: MockDirectoryContent): void {
|
||||
this.remove();
|
||||
|
||||
return this.addContent(root);
|
||||
}
|
||||
|
||||
addContent(root: MockDirectoryContent): void {
|
||||
const entries = this.#transformInput(root);
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = resolvePath(this.#root, entry.path);
|
||||
if (!isChildPath(this.#root, fullPath)) {
|
||||
throw new Error(
|
||||
`Provided path must resolve to a child path of the mock directory, got '${fullPath}'`,
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.type === 'dir') {
|
||||
fs.ensureDirSync(fullPath, { mode: 0o777 });
|
||||
} else if (entry.type === 'file') {
|
||||
fs.ensureDirSync(dirname(fullPath), { mode: 0o777 });
|
||||
fs.writeFileSync(fullPath, entry.content, { mode: 0o666 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content(
|
||||
options?: MockDirectoryContentOptions,
|
||||
): MockDirectoryContent | undefined {
|
||||
const shouldReadAsText =
|
||||
(typeof options?.shouldReadAsText === 'boolean'
|
||||
? () => options?.shouldReadAsText
|
||||
: options?.shouldReadAsText) ??
|
||||
((path: string) => textextensions.includes(extname(path).slice(1)));
|
||||
|
||||
const root = resolvePath(this.#root, options?.path ?? '');
|
||||
if (!isChildPath(this.#root, root)) {
|
||||
throw new Error(
|
||||
`Provided path must resolve to a child path of the mock directory, got '${root}'`,
|
||||
);
|
||||
}
|
||||
|
||||
function read(path: string): MockDirectoryContent | undefined {
|
||||
if (!fs.pathExistsSync(path)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(path, { withFileTypes: true });
|
||||
return Object.fromEntries(
|
||||
entries.map(entry => {
|
||||
const fullPath = resolvePath(path, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
return [entry.name, read(fullPath)];
|
||||
}
|
||||
const content = fs.readFileSync(fullPath);
|
||||
const relativePosixPath = relativePath(root, fullPath)
|
||||
.split(win32.sep)
|
||||
.join(posix.sep);
|
||||
|
||||
if (shouldReadAsText(relativePosixPath, content)) {
|
||||
return [entry.name, content.toString('utf8')];
|
||||
}
|
||||
return [entry.name, content];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return read(root);
|
||||
}
|
||||
|
||||
clear = (): void => {
|
||||
this.setContent({});
|
||||
};
|
||||
|
||||
remove = (): void => {
|
||||
fs.removeSync(this.#root);
|
||||
};
|
||||
|
||||
#transformInput(input: MockDirectoryContent[string]): MockEntry[] {
|
||||
const entries: MockEntry[] = [];
|
||||
|
||||
function traverse(node: MockDirectoryContent[string], path: string) {
|
||||
const trimmedPath = path.startsWith('/') ? path.slice(1) : path; // trim leading slash
|
||||
if (typeof node === 'string') {
|
||||
entries.push({
|
||||
type: 'file',
|
||||
path: trimmedPath,
|
||||
content: Buffer.from(node, 'utf8'),
|
||||
});
|
||||
} else if (node instanceof Buffer) {
|
||||
entries.push({ type: 'file', path: trimmedPath, content: node });
|
||||
} else {
|
||||
entries.push({ type: 'dir', path: trimmedPath });
|
||||
for (const [name, child] of Object.entries(node)) {
|
||||
traverse(child, `${trimmedPath}/${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(input, '');
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link createMockDirectory}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface MockDirectoryOptions {
|
||||
/**
|
||||
* In addition to creating a temporary directory, also mock `os.tmpdir()` to return the
|
||||
* mock directory path until the end of the test suite.
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
mockOsTmpDir?: boolean;
|
||||
|
||||
/**
|
||||
* Initializes the directory with the given content, see {@link MockDirectory.setContent}.
|
||||
*/
|
||||
content?: MockDirectoryContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new temporary mock directory that will be removed after the tests have completed.
|
||||
*
|
||||
* @public
|
||||
* @remarks
|
||||
*
|
||||
* This method is intended to be called outside of any test, either at top-level or
|
||||
* within a `describe` block. It will call `afterAll` to make sure that the mock directory
|
||||
* is removed after the tests have run.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* describe('MySubject', () => {
|
||||
* const mockDir = createMockDirectory();
|
||||
*
|
||||
* beforeEach(mockDir.clear);
|
||||
*
|
||||
* it('should work', () => {
|
||||
* // ... use mockDir
|
||||
* })
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createMockDirectory(
|
||||
options?: MockDirectoryOptions,
|
||||
): MockDirectory {
|
||||
const tmpDir = process.env.RUNNER_TEMP || os.tmpdir(); // GitHub Actions
|
||||
const root = fs.mkdtempSync(joinPath(tmpDir, 'backstage-tmp-test-dir-'));
|
||||
|
||||
const mocker = new MockDirectoryImpl(root);
|
||||
|
||||
const origTmpdir = options?.mockOsTmpDir ? os.tmpdir : undefined;
|
||||
if (origTmpdir) {
|
||||
if (Object.hasOwn(origTmpdir, tmpdirMarker)) {
|
||||
throw new Error(
|
||||
'Cannot mock os.tmpdir() when it has already been mocked',
|
||||
);
|
||||
}
|
||||
const mock = Object.assign(() => mocker.path, { [tmpdirMarker]: true });
|
||||
os.tmpdir = mock;
|
||||
}
|
||||
|
||||
// In CI we expect there to be no need to clean up temporary directories
|
||||
const needsCleanup = !process.env.CI;
|
||||
if (needsCleanup) {
|
||||
process.on('beforeExit', mocker.remove);
|
||||
}
|
||||
|
||||
try {
|
||||
afterAll(() => {
|
||||
if (origTmpdir) {
|
||||
os.tmpdir = origTmpdir;
|
||||
}
|
||||
if (needsCleanup) {
|
||||
mocker.remove();
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (options?.content) {
|
||||
mocker.setContent(options.content);
|
||||
}
|
||||
|
||||
return mocker;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
createMockDirectory,
|
||||
type MockDirectory,
|
||||
type MockDirectoryOptions,
|
||||
type MockDirectoryContent,
|
||||
type MockDirectoryContentOptions,
|
||||
} from './MockDirectory';
|
||||
@@ -22,5 +22,6 @@
|
||||
|
||||
export * from './database';
|
||||
export * from './msw';
|
||||
export * from './filesystem';
|
||||
export * from './next';
|
||||
export * from './util';
|
||||
|
||||
@@ -320,7 +320,9 @@ export function createOAuthRouteHandlers<TProfile>(
|
||||
providerInfo: {
|
||||
idToken: result.session.idToken,
|
||||
accessToken: result.session.accessToken,
|
||||
scope: result.session.scope,
|
||||
scope: authenticator.shouldPersistScopes
|
||||
? scope
|
||||
: result.session.scope,
|
||||
expiresInSeconds: result.session.expiresInSeconds,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -520,6 +520,26 @@ metadata:
|
||||
|
||||
expect(response.body).toStrictEqual({ items: [] });
|
||||
});
|
||||
|
||||
it('should not permit custom auth strategies with dashes', async () => {
|
||||
const throwError = () =>
|
||||
KubernetesBuilder.createBuilder({
|
||||
logger: getVoidLogger(),
|
||||
config,
|
||||
catalogApi,
|
||||
permissions,
|
||||
}).addAuthStrategy('custom-strategy', {
|
||||
getCredential: jest
|
||||
.fn<
|
||||
Promise<KubernetesCredential>,
|
||||
[ClusterDetails, KubernetesRequestAuth]
|
||||
>()
|
||||
.mockResolvedValue({ type: 'anonymous' }),
|
||||
validateCluster: jest.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
expect(throwError).toThrow('Strategy name can not include dashes');
|
||||
});
|
||||
});
|
||||
describe('get /.well-known/backstage/permissions/metadata', () => {
|
||||
it('lists permissions supported by the kubernetes plugin', async () => {
|
||||
|
||||
@@ -205,6 +205,9 @@ export class KubernetesBuilder {
|
||||
}
|
||||
|
||||
public addAuthStrategy(key: string, strategy: AuthenticationStrategy) {
|
||||
if (key.includes('-')) {
|
||||
throw new Error('Strategy name can not include dashes');
|
||||
}
|
||||
this.getAuthStrategyMap()[key] = strategy;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -509,6 +509,134 @@ describe('KubernetesProxy', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should invoke AuthStrategy if Backstage-Kubernetes-Authorization-X-X are provided', async () => {
|
||||
const strategy: jest.Mocked<AuthenticationStrategy> = {
|
||||
getCredential: jest
|
||||
.fn()
|
||||
.mockReturnValue({ type: 'bearer token', token: 'MY_TOKEN3' }),
|
||||
validateCluster: jest.fn(),
|
||||
};
|
||||
|
||||
proxy = new KubernetesProxy({
|
||||
logger: getVoidLogger(),
|
||||
clusterSupplier: clusterSupplier,
|
||||
authStrategy: strategy,
|
||||
});
|
||||
|
||||
worker.use(
|
||||
rest.get('https://localhost:9999/api/v1/namespaces', (req, res, ctx) => {
|
||||
if (!req.headers.get('Authorization')) {
|
||||
return res(ctx.status(401));
|
||||
}
|
||||
|
||||
if (req.headers.get('Authorization') !== 'Bearer MY_TOKEN3') {
|
||||
return res(ctx.status(403));
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
kind: 'NamespaceList',
|
||||
apiVersion: 'v1',
|
||||
items: [],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
clusterSupplier.getClusters.mockResolvedValue([
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'https://localhost:9999',
|
||||
authMetadata: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const requestPromise = setupProxyPromise({
|
||||
proxyPath: '/mountpath',
|
||||
requestPath: '/api/v1/namespaces',
|
||||
|
||||
headers: {
|
||||
[HEADER_KUBERNETES_CLUSTER]: 'cluster1',
|
||||
'Backstage-Kubernetes-Authorization-google': 'MY_TOKEN1',
|
||||
'Backstage-Kubernetes-Authorization-aks': 'MY_TOKEN2',
|
||||
'Backstage-Kubernetes-Authorization-oidc-okta': 'MY_TOKEN3',
|
||||
'Backstage-Kubernetes-Authorization-oidc-gitlab': 'MY_TOKEN4',
|
||||
'Backstage-Kubernetes-Authorization-pinniped-audience1': 'MY_TOKEN5',
|
||||
'Backstage-Kubernetes-Authorization-pinniped-au-b-c-d-e': 'MY_TOKEN6',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await requestPromise;
|
||||
|
||||
const authObj = {
|
||||
google: 'MY_TOKEN1',
|
||||
aks: 'MY_TOKEN2',
|
||||
oidc: { okta: 'MY_TOKEN3', gitlab: 'MY_TOKEN4' },
|
||||
pinniped: { audience1: 'MY_TOKEN5', 'au-b-c-d-e': 'MY_TOKEN6' },
|
||||
};
|
||||
|
||||
expect(strategy.getCredential).toHaveBeenCalledTimes(1);
|
||||
expect(strategy.getCredential).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
authObj,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toStrictEqual({
|
||||
kind: 'NamespaceList',
|
||||
apiVersion: 'v1',
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should invoke the Auth strategy with an empty auth object when no Backstage-Kubernetes-Authorization-X-X are provided', async () => {
|
||||
worker.use(
|
||||
rest.get('https://localhost:9999/api/v1/namespaces', (_, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
kind: 'NamespaceList',
|
||||
apiVersion: 'v1',
|
||||
items: [],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
clusterSupplier.getClusters.mockResolvedValue([
|
||||
{
|
||||
name: 'cluster1',
|
||||
url: 'https://localhost:9999',
|
||||
authMetadata: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const requestPromise = setupProxyPromise({
|
||||
proxyPath: '/mountpath',
|
||||
requestPath: '/api/v1/namespaces',
|
||||
|
||||
headers: {
|
||||
[HEADER_KUBERNETES_CLUSTER]: 'cluster1',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await requestPromise;
|
||||
|
||||
const authObj = {};
|
||||
|
||||
expect(authStrategy.getCredential).toHaveBeenCalledTimes(1);
|
||||
expect(authStrategy.getCredential).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
authObj,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toStrictEqual({
|
||||
kind: 'NamespaceList',
|
||||
apiVersion: 'v1',
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a response with a localKubectlProxy auth provider configuration', async () => {
|
||||
proxy = new KubernetesProxy({
|
||||
logger: getVoidLogger(),
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
serializeError,
|
||||
} from '@backstage/errors';
|
||||
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
|
||||
import { kubernetesProxyPermission } from '@backstage/plugin-kubernetes-common';
|
||||
import {
|
||||
KubernetesRequestAuth,
|
||||
kubernetesProxyPermission,
|
||||
} from '@backstage/plugin-kubernetes-common';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
PermissionEvaluator,
|
||||
@@ -34,6 +37,7 @@ import { AuthenticationStrategy } from '../auth';
|
||||
import { ClusterDetails, KubernetesClustersSupplier } from '../types/types';
|
||||
|
||||
import type { Request } from 'express';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
|
||||
export const APPLICATION_JSON: string = 'application/json';
|
||||
|
||||
@@ -113,9 +117,15 @@ export class KubernetesProxy {
|
||||
if (authHeader) {
|
||||
req.headers.authorization = authHeader;
|
||||
} else {
|
||||
// Map Backstage-Kubernetes-Authorization-X-X headers to a KubernetesRequestAuth object
|
||||
const authObj = KubernetesProxy.authHeadersToKubernetesRequestAuth(
|
||||
req.headers,
|
||||
);
|
||||
|
||||
const credential = await this.getClusterForRequest(req).then(cd => {
|
||||
return this.authStrategy.getCredential(cd, {});
|
||||
return this.authStrategy.getCredential(cd, authObj);
|
||||
});
|
||||
|
||||
if (credential.type === 'bearer token') {
|
||||
req.headers.authorization = `Bearer ${credential.token}`;
|
||||
}
|
||||
@@ -221,4 +231,52 @@ export class KubernetesProxy {
|
||||
|
||||
return cluster;
|
||||
}
|
||||
|
||||
private static authHeadersToKubernetesRequestAuth(
|
||||
originalHeaders: IncomingHttpHeaders,
|
||||
): KubernetesRequestAuth {
|
||||
return Object.keys(originalHeaders)
|
||||
.filter(header => header.startsWith('backstage-kubernetes-authorization'))
|
||||
.map(header =>
|
||||
KubernetesProxy.headerToDictionary(header, originalHeaders),
|
||||
)
|
||||
.filter(headerAsDic => Object.keys(headerAsDic).length !== 0)
|
||||
.reduce(KubernetesProxy.combineHeaders, {});
|
||||
}
|
||||
|
||||
private static headerToDictionary(
|
||||
header: string,
|
||||
originalHeaders: IncomingHttpHeaders,
|
||||
): KubernetesRequestAuth {
|
||||
const obj: KubernetesRequestAuth = {};
|
||||
const headerSplitted = header.split('-');
|
||||
if (headerSplitted.length >= 4) {
|
||||
const framework = headerSplitted[3].toLowerCase();
|
||||
if (headerSplitted.length >= 5) {
|
||||
const provider = headerSplitted.slice(4).join('-').toLowerCase();
|
||||
obj[framework] = { [provider]: originalHeaders[header] };
|
||||
} else {
|
||||
obj[framework] = originalHeaders[header];
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
private static combineHeaders(
|
||||
authObj: any,
|
||||
header: any,
|
||||
): KubernetesRequestAuth {
|
||||
const framework = Object.keys(header)[0];
|
||||
|
||||
if (authObj[framework]) {
|
||||
authObj[framework] = {
|
||||
...authObj[framework],
|
||||
...header[framework],
|
||||
};
|
||||
} else {
|
||||
authObj[framework] = header[framework];
|
||||
}
|
||||
|
||||
return authObj;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,24 @@
|
||||
## API Report File for "@backstage/plugin-kubernetes-cluster"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
/// <reference types="react" />
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { default as React_2 } from 'react';
|
||||
|
||||
// @public
|
||||
export const EntityKubernetesClusterContent: (
|
||||
props: EntityKubernetesClusterContentProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export type EntityKubernetesClusterContentProps = {};
|
||||
|
||||
// @public (undocumented)
|
||||
export const isKubernetesClusterAvailable: (entity: Entity) => boolean;
|
||||
|
||||
// @public (undocumented)
|
||||
export const Router: () => React_2.JSX.Element;
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: backstage-plugin-kubernetes-cluster
|
||||
title: '@backstage/plugin-kubernetes-cluster'
|
||||
description: A Backstage plugin that integrates towards Kubernetes clusters
|
||||
spec:
|
||||
lifecycle: experimental
|
||||
type: backstage-frontend-plugin
|
||||
owner: kubernetes-maintainers
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "@backstage/plugin-kubernetes-cluster",
|
||||
"description": "A Backstage plugin that shows details of Kubernetes clusters",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "frontend-plugin"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "plugins/kubernetes-cluster"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage",
|
||||
"kubernetes"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
"start": "backstage-cli package start",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"clean": "backstage-cli package clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/core-components": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/plugin-catalog-react": "workspace:^",
|
||||
"@backstage/plugin-kubernetes-common": "workspace:^",
|
||||
"@backstage/plugin-kubernetes-react": "workspace:^",
|
||||
"@backstage/theme": "workspace:^",
|
||||
"@kubernetes-models/apimachinery": "^1.1.0",
|
||||
"@kubernetes-models/base": "^4.0.1",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"cronstrue": "^2.2.0",
|
||||
"js-yaml": "^4.0.0",
|
||||
"kubernetes-models": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.0.0",
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17.0.0",
|
||||
"react-dom": "^16.13.1 || ^17.0.0",
|
||||
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/dev-utils": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/dom": "^8.0.0",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^12.1.3",
|
||||
"@testing-library/react-hooks": "^8.0.0",
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/node": "^16.11.26",
|
||||
"msw": "^1.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2020 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 React from 'react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { MissingAnnotationEmptyState } from '@backstage/core-components';
|
||||
import { ANNOTATION_KUBERNETES_API_SERVER } from '@backstage/plugin-kubernetes-common';
|
||||
import { KubernetesClusterContent } from './components/KubernetesClusterContent';
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const isKubernetesClusterAvailable = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[ANNOTATION_KUBERNETES_API_SERVER]);
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const Router = () => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
if (isKubernetesClusterAvailable(entity)) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<KubernetesClusterContent />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MissingAnnotationEmptyState
|
||||
annotation={ANNOTATION_KUBERNETES_API_SERVER}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2020 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 React from 'react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { ApiResources } from './ApiResources';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
jest.mock('@backstage/plugin-catalog-react', () => ({
|
||||
useEntity: () => {
|
||||
return {
|
||||
entity: {
|
||||
metadata: {
|
||||
name: 'some-cluster',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./useApiResources', () => ({
|
||||
useApiResources: jest.fn().mockReturnValue({
|
||||
loading: false,
|
||||
value: {
|
||||
groups: [
|
||||
{
|
||||
name: 'some-apiVersion',
|
||||
preferredVersion: {
|
||||
groupVersion: 'some-group-version',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ApiResources', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('displays ApiResources', async () => {
|
||||
const { getByText } = await renderInTestApp(<ApiResources />);
|
||||
|
||||
// Title
|
||||
expect(getByText('Name')).toBeInTheDocument();
|
||||
expect(getByText('Preferred Version')).toBeInTheDocument();
|
||||
|
||||
// Row 1
|
||||
expect(getByText('some-apiVersion')).toBeInTheDocument();
|
||||
expect(getByText('some-group-version')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2023 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 { useApiResources } from './useApiResources';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { Table, TableColumn } from '@backstage/core-components';
|
||||
import { IAPIGroup } from '@kubernetes-models/apimachinery/apis/meta/v1';
|
||||
import { useKubernetesClusterError } from '../KubernetesClusterErrorContext/KubernetesClusterErrorContext';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
empty: {
|
||||
padding: theme.spacing(2),
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}));
|
||||
|
||||
const defaultColumns: TableColumn<IAPIGroup>[] = [
|
||||
{
|
||||
title: 'Name',
|
||||
highlight: true,
|
||||
render: (apiGroup: IAPIGroup) => {
|
||||
return apiGroup.name;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Preferred Version',
|
||||
highlight: true,
|
||||
render: (apiGroup: IAPIGroup) => {
|
||||
return apiGroup.preferredVersion?.groupVersion;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const ApiResources = () => {
|
||||
const classes = useStyles();
|
||||
const { entity } = useEntity();
|
||||
const { setError } = useKubernetesClusterError();
|
||||
const setErrorCallback = useCallback(setError, [setError]);
|
||||
const { value, error, loading } = useApiResources({
|
||||
clusterName: entity.metadata.name,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setErrorCallback(error.message);
|
||||
}
|
||||
}, [error, setErrorCallback]);
|
||||
|
||||
return (
|
||||
<Table
|
||||
title="API Resources"
|
||||
options={{ paging: true, search: false, emptyRowsWhenPaging: false }}
|
||||
isLoading={!value && loading}
|
||||
data={value?.groups ?? []}
|
||||
emptyContent={
|
||||
<div className={classes.empty}>
|
||||
{error !== undefined
|
||||
? 'Error loading API Resources'
|
||||
: 'No API Resources found'}
|
||||
</div>
|
||||
}
|
||||
columns={defaultColumns}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2023 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 useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { kubernetesApiRef } from '@backstage/plugin-kubernetes-react';
|
||||
import { IAPIGroupList } from '@kubernetes-models/apimachinery/apis/meta/v1';
|
||||
|
||||
/**
|
||||
* Arguments for useApiResources
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ApiResourcesOptions {
|
||||
clusterName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the logs for the given pod
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const useApiResources = ({ clusterName }: ApiResourcesOptions) => {
|
||||
const kubernetesApi = useApi(kubernetesApiRef);
|
||||
return useAsync(async () => {
|
||||
return await kubernetesApi
|
||||
.proxy({
|
||||
clusterName,
|
||||
path: '/apis',
|
||||
})
|
||||
.then(r => {
|
||||
return r.json() as Promise<IAPIGroupList>;
|
||||
});
|
||||
}, [clusterName]);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2020 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 React from 'react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { ClusterOverview } from './ClusterOverview';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
jest.mock('@backstage/plugin-catalog-react', () => ({
|
||||
useEntity: () => {
|
||||
return {
|
||||
entity: {
|
||||
metadata: {
|
||||
name: 'some-cluster',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./useCluster', () => ({
|
||||
useCluster: jest.fn().mockReturnValue({
|
||||
loading: false,
|
||||
value: {
|
||||
name: 'some-cluster',
|
||||
authProvider: 'google',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ClusterOverview', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('displays ClusterOverview', async () => {
|
||||
const { getByText, queryAllByText } = await renderInTestApp(
|
||||
<ClusterOverview />,
|
||||
);
|
||||
|
||||
expect(getByText('Name')).toBeInTheDocument();
|
||||
expect(getByText('some-cluster')).toBeInTheDocument();
|
||||
expect(getByText('Backstage Auth Provider')).toBeInTheDocument();
|
||||
expect(getByText('google')).toBeInTheDocument();
|
||||
expect(getByText('OIDC Token Provider')).toBeInTheDocument();
|
||||
expect(getByText('Dashboard Link')).toBeInTheDocument();
|
||||
expect(queryAllByText('N/A')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright 2023 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 React, { useCallback, useEffect } from 'react';
|
||||
import { InfoCard, StructuredMetadataTable } from '@backstage/core-components';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { useCluster } from './useCluster';
|
||||
import { Skeleton } from '@material-ui/lab';
|
||||
import { Theme, createStyles, makeStyles } from '@material-ui/core';
|
||||
import { useKubernetesClusterError } from '../KubernetesClusterErrorContext/KubernetesClusterErrorContext';
|
||||
|
||||
const useStyles = makeStyles((_theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
height: '100%',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const ClusterOverview = () => {
|
||||
const classes = useStyles();
|
||||
const { entity } = useEntity();
|
||||
const { value, loading, error } = useCluster({
|
||||
clusterName: entity.metadata.name,
|
||||
});
|
||||
const { setError } = useKubernetesClusterError();
|
||||
const setErrorCallback = useCallback(setError, [setError]);
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setErrorCallback(error.message);
|
||||
}
|
||||
}, [error, setErrorCallback]);
|
||||
|
||||
return (
|
||||
<InfoCard title="Cluster Overview" className={classes.root}>
|
||||
{!value && loading && (
|
||||
<>
|
||||
<Skeleton height="35rem" />
|
||||
</>
|
||||
)}
|
||||
{value && (
|
||||
<StructuredMetadataTable
|
||||
metadata={{
|
||||
name: value.name,
|
||||
'Backstage auth provider': value.authProvider,
|
||||
'OIDC Token Provider': value.oidcTokenProvider ?? 'N/A',
|
||||
'Dashboard Link': value.dashboardUrl ?? 'N/A',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
export { ClusterOverview } from './ClusterOverview';
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2023 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 useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { kubernetesApiRef } from '@backstage/plugin-kubernetes-react';
|
||||
|
||||
/**
|
||||
* Arguments for useApiResources
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface UseClusterOptions {
|
||||
clusterName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the logs for the given pod
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const useCluster = ({ clusterName }: UseClusterOptions) => {
|
||||
const kubernetesApi = useApi(kubernetesApiRef);
|
||||
return useAsync(async () => {
|
||||
return await kubernetesApi.getCluster(clusterName);
|
||||
}, [clusterName]);
|
||||
};
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2023 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 React from 'react';
|
||||
import { ApiResources } from '../ApiResources/ApiResources';
|
||||
import { Grid, Typography } from '@material-ui/core';
|
||||
import { Nodes } from '../Nodes/Nodes';
|
||||
import { ClusterOverview } from '../ClusterOverview';
|
||||
import {
|
||||
KubernetesClusterErrorProvider,
|
||||
useKubernetesClusterError,
|
||||
} from '../KubernetesClusterErrorContext/KubernetesClusterErrorContext';
|
||||
import { WarningPanel } from '@backstage/core-components';
|
||||
|
||||
const ContentGrid = () => {
|
||||
const { error } = useKubernetesClusterError();
|
||||
return (
|
||||
<>
|
||||
<Grid container>
|
||||
{error && (
|
||||
<Grid item xs={12}>
|
||||
<WarningPanel title="Error loading Kubernetes Cluster Plugin">
|
||||
<Typography>{error}</Typography>
|
||||
</WarningPanel>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={6}>
|
||||
<ClusterOverview />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<ApiResources />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Nodes />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const KubernetesClusterContent = () => {
|
||||
return (
|
||||
<KubernetesClusterErrorProvider>
|
||||
<ContentGrid />
|
||||
</KubernetesClusterErrorProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
export { KubernetesClusterContent } from './KubernetesClusterContent';
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2023 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 React, { useCallback, useContext, useState } from 'react';
|
||||
|
||||
export interface ErrorContext {
|
||||
error?: string;
|
||||
setError: (message: string) => void;
|
||||
}
|
||||
|
||||
export const KubernetesClusterErrorContext = React.createContext<ErrorContext>({
|
||||
setError: (_: string) => {},
|
||||
});
|
||||
|
||||
export interface KubernetesClusterErrorProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const KubernetesClusterErrorProvider = ({
|
||||
children,
|
||||
}: KubernetesClusterErrorProviderProps) => {
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const contextValue: ErrorContext = {
|
||||
error,
|
||||
setError: useCallback((message: string) => setError(message), []),
|
||||
};
|
||||
|
||||
return (
|
||||
<KubernetesClusterErrorContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</KubernetesClusterErrorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useKubernetesClusterError = () => {
|
||||
return useContext(KubernetesClusterErrorContext);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright 2020 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 React from 'react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { Nodes } from './Nodes';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
jest.mock('@backstage/plugin-catalog-react', () => ({
|
||||
useEntity: () => {
|
||||
return {
|
||||
entity: {
|
||||
metadata: {
|
||||
name: 'some-cluster',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./useNodes', () => ({
|
||||
useNodes: jest.fn().mockReturnValue({
|
||||
loading: false,
|
||||
value: {
|
||||
items: [
|
||||
{
|
||||
metadata: {
|
||||
name: 'some-node-name',
|
||||
},
|
||||
status: {
|
||||
conditions: [
|
||||
{
|
||||
type: 'Ready',
|
||||
status: 'True',
|
||||
},
|
||||
],
|
||||
nodeInfo: {
|
||||
operatingSystem: 'linux',
|
||||
architecture: 'ARM',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
name: 'some-node-bad-name',
|
||||
},
|
||||
spec: {
|
||||
unschedulable: true,
|
||||
},
|
||||
status: {
|
||||
conditions: [
|
||||
{
|
||||
type: 'Ready',
|
||||
status: 'False',
|
||||
},
|
||||
],
|
||||
nodeInfo: {
|
||||
operatingSystem: 'windows',
|
||||
architecture: 'x86',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Nodes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('displays nodes table - ready schedulable node', async () => {
|
||||
const { getByText } = await renderInTestApp(<Nodes />);
|
||||
|
||||
expect(getByText('Nodes')).toBeInTheDocument();
|
||||
|
||||
// Titles
|
||||
expect(getByText('Name')).toBeInTheDocument();
|
||||
expect(getByText('Schedulable')).toBeInTheDocument();
|
||||
expect(getByText('Status')).toBeInTheDocument();
|
||||
expect(getByText('OS')).toBeInTheDocument();
|
||||
|
||||
// Row 1
|
||||
expect(getByText('some-node-name')).toBeInTheDocument();
|
||||
expect(getByText('✅')).toBeInTheDocument();
|
||||
expect(getByText('Ready')).toBeInTheDocument();
|
||||
expect(getByText('linux (ARM)')).toBeInTheDocument();
|
||||
|
||||
// Row 2
|
||||
expect(getByText('some-node-bad-name')).toBeInTheDocument();
|
||||
expect(getByText('❌')).toBeInTheDocument();
|
||||
expect(getByText('Not Ready')).toBeInTheDocument();
|
||||
expect(getByText('windows (x86)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright 2023 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 { useNodes } from './useNodes';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
StructuredMetadataTable,
|
||||
Table,
|
||||
TableColumn,
|
||||
} from '@backstage/core-components';
|
||||
import { INode } from 'kubernetes-models/v1';
|
||||
import { Grid, Typography, makeStyles } from '@material-ui/core';
|
||||
import { useKubernetesClusterError } from '../KubernetesClusterErrorContext/KubernetesClusterErrorContext';
|
||||
import { KubernetesDrawer } from '@backstage/plugin-kubernetes-react';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
empty: {
|
||||
padding: theme.spacing(2),
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}));
|
||||
|
||||
const defaultColumns: TableColumn<INode>[] = [
|
||||
{
|
||||
title: 'Name',
|
||||
highlight: true,
|
||||
width: 'auto',
|
||||
render: (node: INode) => {
|
||||
return (
|
||||
<KubernetesDrawer
|
||||
kubernetesObject={node}
|
||||
label={node.metadata?.name ?? 'unknown-node'}
|
||||
>
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h5">Node Info</Typography>
|
||||
<StructuredMetadataTable metadata={node.status?.nodeInfo ?? {}} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h5">Addresses</Typography>
|
||||
<StructuredMetadataTable
|
||||
metadata={
|
||||
node.status?.addresses?.reduce((accum, next) => {
|
||||
accum[next.type] = next.address;
|
||||
return accum;
|
||||
}, {} as any) ?? {}
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h5">Taints</Typography>
|
||||
<StructuredMetadataTable
|
||||
metadata={
|
||||
node.spec?.taints?.reduce((accum, next) => {
|
||||
accum[`${next.effect}`] = `${next.key} (${next.value})`;
|
||||
return accum;
|
||||
}, {} as any) ?? {}
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</KubernetesDrawer>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Schedulable',
|
||||
align: 'center',
|
||||
width: 'auto',
|
||||
render: (node: INode) => {
|
||||
if (node.spec?.unschedulable) {
|
||||
return '❌';
|
||||
}
|
||||
return '✅';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
width: 'auto',
|
||||
render: (node: INode) => {
|
||||
// TODO add an icon
|
||||
const readyCondition = node.status?.conditions?.find(c => {
|
||||
return c.type === 'Ready';
|
||||
});
|
||||
if (!readyCondition) {
|
||||
return 'Unknown';
|
||||
}
|
||||
return readyCondition.status === 'True' ? 'Ready' : 'Not Ready';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'OS',
|
||||
width: 'auto',
|
||||
render: (node: INode) => {
|
||||
return `${node.status?.nodeInfo?.operatingSystem ?? 'unknown'} (${
|
||||
node.status?.nodeInfo?.architecture ?? '?'
|
||||
})`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const Nodes = () => {
|
||||
const classes = useStyles();
|
||||
const { entity } = useEntity();
|
||||
const { value, error, loading } = useNodes({
|
||||
clusterName: entity.metadata.name,
|
||||
});
|
||||
const { setError } = useKubernetesClusterError();
|
||||
const setErrorCallback = useCallback(setError, [setError]);
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setErrorCallback(error.message);
|
||||
}
|
||||
}, [error, setErrorCallback]);
|
||||
|
||||
return (
|
||||
<Table
|
||||
title="Nodes"
|
||||
options={{ paging: true, search: false, emptyRowsWhenPaging: false }}
|
||||
isLoading={!value && loading}
|
||||
data={value?.items ?? []}
|
||||
emptyContent={
|
||||
<div className={classes.empty}>
|
||||
{error !== undefined ? 'Error loading nodes' : 'No nodes found'}
|
||||
</div>
|
||||
}
|
||||
columns={defaultColumns}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2023 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 useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { kubernetesApiRef } from '@backstage/plugin-kubernetes-react';
|
||||
import { NodeList } from 'kubernetes-models/v1';
|
||||
|
||||
/**
|
||||
* Arguments for useApiResources
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface useNodesOptions {
|
||||
clusterName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves nodes for a cluster
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const useNodes = ({ clusterName }: useNodesOptions) => {
|
||||
const kubernetesApi = useApi(kubernetesApiRef);
|
||||
return useAsync(async () => {
|
||||
return await kubernetesApi
|
||||
.proxy({
|
||||
clusterName,
|
||||
path: '/api/v1/nodes?limit=500',
|
||||
})
|
||||
.then(r => {
|
||||
return r.json() as Promise<NodeList>;
|
||||
});
|
||||
}, [clusterName]);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2023 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A Backstage plugin that integrates towards Kubernetes
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export {
|
||||
EntityKubernetesClusterContent,
|
||||
type EntityKubernetesClusterContentProps,
|
||||
} from './plugin';
|
||||
export { Router, isKubernetesClusterAvailable } from './Router';
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2020 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 {
|
||||
createPlugin,
|
||||
createRouteRef,
|
||||
createRoutableExtension,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
export const rootCatalogKubernetesClusterRouteRef = createRouteRef({
|
||||
id: 'kubernetes-cluster',
|
||||
});
|
||||
|
||||
export const kubernetesClusterPlugin = createPlugin({
|
||||
id: 'kubernetes-cluster',
|
||||
apis: [],
|
||||
routes: {
|
||||
entityContent: rootCatalogKubernetesClusterRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Props of EntityKubernetesContent
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type EntityKubernetesClusterContentProps = {};
|
||||
|
||||
/**
|
||||
* Props of EntityKubernetesContent
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const EntityKubernetesClusterContent: (
|
||||
props: EntityKubernetesClusterContentProps,
|
||||
) => JSX.Element = kubernetesClusterPlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'EntityKubernetesClusterContent',
|
||||
component: () => import('./Router').then(m => m.Router),
|
||||
mountPoint: rootCatalogKubernetesClusterRouteRef,
|
||||
}),
|
||||
);
|
||||
@@ -7,6 +7,7 @@ import { BasicPermission } from '@backstage/plugin-permission-common';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { FetchResponse as FetchResponse_2 } from '@backstage/plugin-kubernetes-common';
|
||||
import type { JsonObject } from '@backstage/types';
|
||||
import type { JsonValue } from '@backstage/types';
|
||||
import { ObjectsByEntityResponse as ObjectsByEntityResponse_2 } from '@backstage/plugin-kubernetes-common';
|
||||
import { PodStatus } from '@kubernetes/client-node';
|
||||
import { V1ConfigMap } from '@kubernetes/client-node';
|
||||
@@ -319,7 +320,9 @@ export const kubernetesPermissions: BasicPermission[];
|
||||
export const kubernetesProxyPermission: BasicPermission;
|
||||
|
||||
// @public (undocumented)
|
||||
export type KubernetesRequestAuth = JsonObject;
|
||||
export type KubernetesRequestAuth = {
|
||||
[providerKey: string]: JsonValue | undefined;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface KubernetesRequestBody {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { JsonObject } from '@backstage/types';
|
||||
import type { JsonObject, JsonValue } from '@backstage/types';
|
||||
import {
|
||||
PodStatus,
|
||||
V1ConfigMap,
|
||||
@@ -33,7 +33,9 @@ import {
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
/** @public */
|
||||
export type KubernetesRequestAuth = JsonObject;
|
||||
export type KubernetesRequestAuth = {
|
||||
[providerKey: string]: JsonValue | undefined;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export interface CustomResourceMatcher {
|
||||
|
||||
@@ -251,12 +251,22 @@ export type JobsAccordionsProps = {
|
||||
|
||||
// @public (undocumented)
|
||||
export interface KubernetesApi {
|
||||
// (undocumented)
|
||||
getCluster(clusterName: string): Promise<
|
||||
| {
|
||||
name: string;
|
||||
authProvider: string;
|
||||
oidcTokenProvider?: string;
|
||||
dashboardUrl?: string;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
// (undocumented)
|
||||
getClusters(): Promise<
|
||||
{
|
||||
name: string;
|
||||
authProvider: string;
|
||||
oidcTokenProvider?: string | undefined;
|
||||
oidcTokenProvider?: string;
|
||||
}[]
|
||||
>;
|
||||
// (undocumented)
|
||||
@@ -338,6 +348,12 @@ export class KubernetesBackendClient implements KubernetesApi {
|
||||
kubernetesAuthProvidersApi: KubernetesAuthProvidersApi;
|
||||
});
|
||||
// (undocumented)
|
||||
getCluster(clusterName: string): Promise<{
|
||||
name: string;
|
||||
authProvider: string;
|
||||
oidcTokenProvider?: string;
|
||||
}>;
|
||||
// (undocumented)
|
||||
getClusters(): Promise<
|
||||
{
|
||||
name: string;
|
||||
|
||||
@@ -398,9 +398,26 @@ describe('KubernetesBackendClient', () => {
|
||||
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
|
||||
});
|
||||
|
||||
it('hits the /proxy API', async () => {
|
||||
it('hits the /proxy API with oidc as protocol and okta as auth provider', async () => {
|
||||
worker.use(
|
||||
rest.get(
|
||||
'http://localhost:1234/api/kubernetes/clusters',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
items: [
|
||||
{
|
||||
name: 'cluster-a',
|
||||
authProvider: 'oidc',
|
||||
oidcTokenProvider: 'okta',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({
|
||||
token: 'k8-token',
|
||||
token: 'k8-token3',
|
||||
});
|
||||
const nsResponse = {
|
||||
kind: 'Namespace',
|
||||
@@ -414,8 +431,56 @@ describe('KubernetesBackendClient', () => {
|
||||
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
|
||||
(req, res, ctx) =>
|
||||
res(
|
||||
req.headers.get('Backstage-Kubernetes-Authorization') ===
|
||||
'Bearer k8-token'
|
||||
req.headers.get(
|
||||
'Backstage-Kubernetes-Authorization-oidc-okta',
|
||||
) === 'k8-token3'
|
||||
? ctx.json(nsResponse)
|
||||
: ctx.status(403),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const request = {
|
||||
clusterName: 'cluster-a',
|
||||
path: '/api/v1/namespaces',
|
||||
};
|
||||
|
||||
const response = await backendClient.proxy(request);
|
||||
|
||||
await expect(response.json()).resolves.toStrictEqual(nsResponse);
|
||||
});
|
||||
|
||||
it('hits the /proxy API with serviceAccount as auth provider', async () => {
|
||||
worker.use(
|
||||
rest.get(
|
||||
'http://localhost:1234/api/kubernetes/clusters',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
items: [
|
||||
{
|
||||
name: 'cluster-a',
|
||||
authProvider: 'serviceAccount',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const nsResponse = {
|
||||
kind: 'Namespace',
|
||||
apiVersion: 'v1',
|
||||
metadata: {
|
||||
name: 'new-ns',
|
||||
},
|
||||
};
|
||||
worker.use(
|
||||
rest.get(
|
||||
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
|
||||
(req, res, ctx) =>
|
||||
res(
|
||||
req.headers.get('Authorization') === 'Bearer idToken'
|
||||
? ctx.json(nsResponse)
|
||||
: ctx.status(403),
|
||||
),
|
||||
@@ -450,8 +515,8 @@ describe('KubernetesBackendClient', () => {
|
||||
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
|
||||
(req, res, ctx) =>
|
||||
res(
|
||||
req.headers.get('Backstage-Kubernetes-Authorization') ===
|
||||
'Bearer k8-token'
|
||||
req.headers.get('Backstage-Kubernetes-Authorization-aws') ===
|
||||
'k8-token'
|
||||
? ctx.json(nsResponse)
|
||||
: ctx.status(403),
|
||||
),
|
||||
|
||||
@@ -75,9 +75,11 @@ export class KubernetesBackendClient implements KubernetesApi {
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
private async getCluster(
|
||||
clusterName: string,
|
||||
): Promise<{ name: string; authProvider: string }> {
|
||||
public async getCluster(clusterName: string): Promise<{
|
||||
name: string;
|
||||
authProvider: string;
|
||||
oidcTokenProvider?: string;
|
||||
}> {
|
||||
const cluster = await this.getClusters().then(clusters =>
|
||||
clusters.find(c => c.name === clusterName),
|
||||
);
|
||||
@@ -140,23 +142,62 @@ export class KubernetesBackendClient implements KubernetesApi {
|
||||
path: string;
|
||||
init?: RequestInit;
|
||||
}): Promise<Response> {
|
||||
const { authProvider } = await this.getCluster(options.clusterName);
|
||||
const { token: k8sToken } = await this.getCredentials(authProvider);
|
||||
const { authProvider, oidcTokenProvider } = await this.getCluster(
|
||||
options.clusterName,
|
||||
);
|
||||
const kubernetesCredentials = await this.getCredentials(authProvider);
|
||||
const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/proxy${
|
||||
options.path
|
||||
}`;
|
||||
const identityResponse = await this.identityApi.getCredentials();
|
||||
const headers = {
|
||||
const headers = KubernetesBackendClient.getKubernetesHeaders(
|
||||
options,
|
||||
kubernetesCredentials?.token,
|
||||
identityResponse,
|
||||
authProvider,
|
||||
oidcTokenProvider,
|
||||
);
|
||||
return await fetch(url, { ...options.init, headers });
|
||||
}
|
||||
|
||||
private static getKubernetesHeaders(
|
||||
options: {
|
||||
clusterName: string;
|
||||
path: string;
|
||||
init?: RequestInit;
|
||||
},
|
||||
k8sToken: string | undefined,
|
||||
identityResponse: { token?: string },
|
||||
authProvider: string,
|
||||
oidcTokenProvider: string | undefined,
|
||||
) {
|
||||
const kubernetesAuthHeader =
|
||||
KubernetesBackendClient.getKubernetesAuthHeaderByAuthProvider(
|
||||
authProvider,
|
||||
oidcTokenProvider,
|
||||
);
|
||||
return {
|
||||
...options.init?.headers,
|
||||
[`Backstage-Kubernetes-Cluster`]: options.clusterName,
|
||||
...(k8sToken && {
|
||||
[`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`,
|
||||
[kubernetesAuthHeader]: k8sToken,
|
||||
}),
|
||||
...(identityResponse.token && {
|
||||
Authorization: `Bearer ${identityResponse.token}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return await fetch(url, { ...options.init, headers });
|
||||
private static getKubernetesAuthHeaderByAuthProvider(
|
||||
authProvider: string,
|
||||
oidcTokenProvider: string | undefined,
|
||||
): string {
|
||||
let header: string = 'Backstage-Kubernetes-Authorization';
|
||||
|
||||
header = header.concat('-', authProvider);
|
||||
|
||||
if (oidcTokenProvider) header = header.concat('-', oidcTokenProvider);
|
||||
|
||||
return header;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,18 @@ export interface KubernetesApi {
|
||||
{
|
||||
name: string;
|
||||
authProvider: string;
|
||||
oidcTokenProvider?: string | undefined;
|
||||
oidcTokenProvider?: string;
|
||||
}[]
|
||||
>;
|
||||
getCluster(clusterName: string): Promise<
|
||||
| {
|
||||
name: string;
|
||||
authProvider: string;
|
||||
oidcTokenProvider?: string;
|
||||
dashboardUrl?: string;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
getWorkloadsByEntity(
|
||||
request: WorkloadsByEntityRequest,
|
||||
): Promise<ObjectsByEntityResponse>;
|
||||
|
||||
@@ -116,6 +116,12 @@ class MockKubernetesClient implements KubernetesApi {
|
||||
return [{ name: 'mock-cluster', authProvider: 'serviceAccount' }];
|
||||
}
|
||||
|
||||
async getCluster(
|
||||
_clusterName: string,
|
||||
): Promise<{ name: string; authProvider: string }> {
|
||||
return { name: 'mock-cluster', authProvider: 'serviceAccount' };
|
||||
}
|
||||
|
||||
async proxy(_options: { clusterName: String; path: String }): Promise<any> {
|
||||
return {
|
||||
kind: 'Namespace',
|
||||
|
||||
@@ -3464,7 +3464,6 @@ __metadata:
|
||||
"@types/http-errors": ^2.0.0
|
||||
"@types/luxon": ^3.0.0
|
||||
"@types/minimist": ^1.2.0
|
||||
"@types/mock-fs": ^4.13.0
|
||||
"@types/morgan": ^1.9.0
|
||||
"@types/node-forge": ^1.3.0
|
||||
"@types/pg": ^8.6.6
|
||||
@@ -3497,7 +3496,6 @@ __metadata:
|
||||
luxon: ^3.0.0
|
||||
minimatch: ^5.0.0
|
||||
minimist: ^1.2.5
|
||||
mock-fs: ^5.2.0
|
||||
morgan: ^1.10.0
|
||||
msw: ^1.0.0
|
||||
mysql2: ^2.2.5
|
||||
@@ -3645,17 +3643,20 @@ __metadata:
|
||||
"@backstage/backend-plugin-api": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/config": "workspace:^"
|
||||
"@backstage/errors": "workspace:^"
|
||||
"@backstage/plugin-auth-node": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
"@types/supertest": ^2.0.8
|
||||
better-sqlite3: ^8.0.0
|
||||
express: ^4.17.1
|
||||
fs-extra: ^10.0.1
|
||||
knex: ^2.0.0
|
||||
msw: ^1.0.0
|
||||
mysql2: ^2.2.5
|
||||
pg: ^8.3.0
|
||||
supertest: ^6.1.3
|
||||
testcontainers: ^8.1.2
|
||||
textextensions: ^5.16.0
|
||||
uuid: ^8.0.0
|
||||
peerDependencies:
|
||||
"@types/jest": "*"
|
||||
@@ -7642,6 +7643,49 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@backstage/plugin-kubernetes-cluster@workspace:^, @backstage/plugin-kubernetes-cluster@workspace:plugins/kubernetes-cluster":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/plugin-kubernetes-cluster@workspace:plugins/kubernetes-cluster"
|
||||
dependencies:
|
||||
"@backstage/catalog-model": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/config": "workspace:^"
|
||||
"@backstage/core-app-api": "workspace:^"
|
||||
"@backstage/core-components": "workspace:^"
|
||||
"@backstage/core-plugin-api": "workspace:^"
|
||||
"@backstage/dev-utils": "workspace:^"
|
||||
"@backstage/errors": "workspace:^"
|
||||
"@backstage/plugin-catalog-react": "workspace:^"
|
||||
"@backstage/plugin-kubernetes-common": "workspace:^"
|
||||
"@backstage/plugin-kubernetes-react": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/theme": "workspace:^"
|
||||
"@kubernetes-models/apimachinery": ^1.1.0
|
||||
"@kubernetes-models/base": ^4.0.1
|
||||
"@material-ui/core": ^4.12.2
|
||||
"@material-ui/icons": ^4.9.1
|
||||
"@material-ui/lab": 4.0.0-alpha.61
|
||||
"@testing-library/dom": ^8.0.0
|
||||
"@testing-library/jest-dom": ^5.10.1
|
||||
"@testing-library/react": ^12.1.3
|
||||
"@testing-library/react-hooks": ^8.0.0
|
||||
"@testing-library/user-event": ^14.0.0
|
||||
"@types/node": ^16.11.26
|
||||
"@types/react": ^16.13.1 || ^17.0.0
|
||||
cronstrue: ^2.2.0
|
||||
js-yaml: ^4.0.0
|
||||
kubernetes-models: ^4.1.0
|
||||
lodash: ^4.17.21
|
||||
luxon: ^3.0.0
|
||||
msw: ^1.0.0
|
||||
react-use: ^17.2.4
|
||||
peerDependencies:
|
||||
react: ^16.13.1 || ^17.0.0
|
||||
react-dom: ^16.13.1 || ^17.0.0
|
||||
react-router-dom: 6.0.0-beta.0 || ^6.3.0
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@backstage/plugin-kubernetes-common@workspace:^, @backstage/plugin-kubernetes-common@workspace:plugins/kubernetes-common":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/plugin-kubernetes-common@workspace:plugins/kubernetes-common"
|
||||
@@ -17988,10 +18032,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:^16.9.2":
|
||||
version: 16.18.53
|
||||
resolution: "@types/node@npm:16.18.53"
|
||||
checksum: 26c05cde59664360c22e0dda70776ca6f1b35f0b94e4f84d2c21e2afa2e69ac3a2c99bbb57b43405f81df1b2598f6d707ccc7c4c31865f90e45c4625d8400518
|
||||
"@types/node@npm:^16.11.26, @types/node@npm:^16.9.2":
|
||||
version: 16.18.54
|
||||
resolution: "@types/node@npm:16.18.54"
|
||||
checksum: 208e8fc64f605e9cd55ab5e620a0fd019d8fe5629e3e3c5de869a149b731ab0fac5720c516dccc0ecc834ac27df754723dfe6554551663f016ba5096ea8851df
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -25543,6 +25587,7 @@ __metadata:
|
||||
"@backstage/plugin-jenkins": "workspace:^"
|
||||
"@backstage/plugin-kafka": "workspace:^"
|
||||
"@backstage/plugin-kubernetes": "workspace:^"
|
||||
"@backstage/plugin-kubernetes-cluster": "workspace:^"
|
||||
"@backstage/plugin-lighthouse": "workspace:^"
|
||||
"@backstage/plugin-linguist": "workspace:^"
|
||||
"@backstage/plugin-linguist-common": "workspace:^"
|
||||
@@ -40465,10 +40510,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"textextensions@npm:^5.12.0, textextensions@npm:^5.13.0":
|
||||
version: 5.14.0
|
||||
resolution: "textextensions@npm:5.14.0"
|
||||
checksum: 1f610ccf2a2c1445fb7156c23b5c8defd608c74e8047df18abd4b9ee44c74d29b453ba104d14c53c91f9026670f7c923114cd12200ee5ec458cc518fd0798c74
|
||||
"textextensions@npm:^5.12.0, textextensions@npm:^5.13.0, textextensions@npm:^5.16.0":
|
||||
version: 5.16.0
|
||||
resolution: "textextensions@npm:5.16.0"
|
||||
checksum: d2abd5c962760046aa85d9ca542bd8bdb451370fc0a5e5f807aa80dd2f50175ec10d5ce9d28ae96968aaf6a1b1bea254cf4715f24852d0dcf29c6a60af7f793c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user