Merge branch 'backstage:master' into feature/support-aoss

This commit is contained in:
Andrew Ochsner
2023-10-04 15:57:58 -05:00
committed by GitHub
62 changed files with 2584 additions and 301 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-test-utils': patch
---
Added `createMockDirectory()` to help out with file system mocking in tests.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Properly close write stream when writing temporary archive for processing zip-based `.readTree()` responses.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Removed `mock-fs` dev dependency.
+8
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-node': patch
---
Fixed cookie persisted scope not returned in OAuth refresh handler response.
+7
View File
@@ -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
+2 -1
View File
@@ -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]
+12 -10
View File
@@ -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
+1
View File
@@ -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"
-2
View File
@@ -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: {
+36
View File
@@ -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)
+3
View File
@@ -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';
+1
View File
@@ -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;
}
}
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+24
View File
@@ -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
+80
View File
@@ -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"
]
}
+56
View File
@@ -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]);
};
@@ -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';
@@ -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]);
};
+27
View File
@@ -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';
+54
View File
@@ -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,
}),
);
+4 -1
View File
@@ -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 {
+4 -2
View File
@@ -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 {
+17 -1
View File
@@ -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;
}
}
+10 -1
View File
@@ -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>;
+6
View File
@@ -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',
+55 -10
View File
@@ -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