diff --git a/.changeset/flat-pandas-tan.md b/.changeset/flat-pandas-tan.md
new file mode 100644
index 0000000000..dfe7965bec
--- /dev/null
+++ b/.changeset/flat-pandas-tan.md
@@ -0,0 +1,5 @@
+---
+'@backstage/backend-test-utils': patch
+---
+
+Added `createMockDirectory()` to help out with file system mocking in tests.
diff --git a/.changeset/great-walls-brake.md b/.changeset/great-walls-brake.md
new file mode 100644
index 0000000000..dd71c81a07
--- /dev/null
+++ b/.changeset/great-walls-brake.md
@@ -0,0 +1,5 @@
+---
+'@backstage/backend-common': patch
+---
+
+Properly close write stream when writing temporary archive for processing zip-based `.readTree()` responses.
diff --git a/.changeset/short-terms-check.md b/.changeset/short-terms-check.md
new file mode 100644
index 0000000000..69d85fcb9c
--- /dev/null
+++ b/.changeset/short-terms-check.md
@@ -0,0 +1,5 @@
+---
+'@backstage/backend-common': patch
+---
+
+Removed `mock-fs` dev dependency.
diff --git a/.changeset/slow-dodos-remember.md b/.changeset/slow-dodos-remember.md
new file mode 100644
index 0000000000..fcc49e3f45
--- /dev/null
+++ b/.changeset/slow-dodos-remember.md
@@ -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
diff --git a/.changeset/stale-stingrays-explode.md b/.changeset/stale-stingrays-explode.md
new file mode 100644
index 0000000000..b8b66b8398
--- /dev/null
+++ b/.changeset/stale-stingrays-explode.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-auth-node': patch
+---
+
+Fixed cookie persisted scope not returned in OAuth refresh handler response.
diff --git a/.changeset/two-dingos-dream.md b/.changeset/two-dingos-dream.md
new file mode 100644
index 0000000000..9ebefacc69
--- /dev/null
+++ b/.changeset/two-dingos-dream.md
@@ -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
diff --git a/.github/workflows/verify_windows.yml b/.github/workflows/verify_windows.yml
index e5d3adf013..312c662b5a 100644
--- a/.github/workflows/verify_windows.yml
+++ b/.github/workflows/verify_windows.yml
@@ -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]
diff --git a/OWNERS.md b/OWNERS.md
index 000a3e2444..135d617203 100644
--- a/OWNERS.md
+++ b/OWNERS.md
@@ -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
diff --git a/packages/app/package.json b/packages/app/package.json
index e23da6c8dc..6e06d2ca03 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -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:^",
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index ae78d0b771..81af3b91e7 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -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 = (
+
+
+
{
);
};
-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'),
diff --git a/packages/backend-common/src/reading/BitbucketCloudUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketCloudUrlReader.test.ts
index c00aeba124..dc182af619 100644
--- a/packages/backend-common/src/reading/BitbucketCloudUrlReader.test.ts
+++ b/packages/backend-common/src/reading/BitbucketCloudUrlReader.test.ts
@@ -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'),
diff --git a/packages/backend-common/src/reading/BitbucketServerUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketServerUrlReader.test.ts
index 9f231e2ba7..2870138535 100644
--- a/packages/backend-common/src/reading/BitbucketServerUrlReader.test.ts
+++ b/packages/backend-common/src/reading/BitbucketServerUrlReader.test.ts
@@ -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);
diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
index 15e007d516..4258ac9d5b 100644
--- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
+++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
@@ -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'),
diff --git a/packages/backend-common/src/reading/GerritUrlReader.test.ts b/packages/backend-common/src/reading/GerritUrlReader.test.ts
index 19c67ffd56..4a3b0a5de6 100644
--- a/packages/backend-common/src/reading/GerritUrlReader.test.ts
+++ b/packages/backend-common/src/reading/GerritUrlReader.test.ts
@@ -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();
});
diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts
index 499f97d6ec..5e6d30f519 100644
--- a/packages/backend-common/src/reading/GithubUrlReader.test.ts
+++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts
@@ -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'),
diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts
index 5af4faf7e5..3c065e8b81 100644
--- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts
+++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts
@@ -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'),
diff --git a/packages/backend-common/src/reading/tree/ReadableArrayResponse.test.ts b/packages/backend-common/src/reading/tree/ReadableArrayResponse.test.ts
index a5728a6169..0a27bdf09c 100644
--- a/packages/backend-common/src/reading/tree/ReadableArrayResponse.test.ts
+++ b/packages/backend-common/src/reading/tree/ReadableArrayResponse.test.ts
@@ -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();
+ 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'),
+ });
});
});
diff --git a/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts
index 1765a41156..f69fb2e0e1 100644
--- a/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts
+++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts
@@ -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,
- []
- >;
- 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);
});
});
diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts
index 0b010565dd..ccf53e7cff 100644
--- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts
+++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts
@@ -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();
+ 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((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',
);
diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts
index 1e55150310..a191163517 100644
--- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts
+++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts
@@ -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;
}
diff --git a/packages/backend-common/src/util/DockerContainerRunner.test.ts b/packages/backend-common/src/util/DockerContainerRunner.test.ts
index 3208679d1f..737bf3d178 100644
--- a/packages/backend-common/src/util/DockerContainerRunner.test.ts
+++ b/packages/backend-common/src/util/DockerContainerRunner.test.ts
@@ -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;
-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: {
diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md
index 4a27ac21a9..a477f70298 100644
--- a/packages/backend-test-utils/api-report.md
+++ b/packages/backend-test-utils/api-report.md
@@ -4,6 +4,7 @@
```ts
///
+///
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)
diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json
index 04be07ed33..691f0a921b 100644
--- a/packages/backend-test-utils/package.json
+++ b/packages/backend-test-utils/package.json
@@ -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": {
diff --git a/packages/backend-test-utils/src/filesystem/MockDirectory.test.ts b/packages/backend-test-utils/src/filesystem/MockDirectory.test.ts
new file mode 100644
index 0000000000..9e3efdaec0
--- /dev/null
+++ b/packages/backend-test-utils/src/filesystem/MockDirectory.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/packages/backend-test-utils/src/filesystem/MockDirectory.ts b/packages/backend-test-utils/src/filesystem/MockDirectory.ts
new file mode 100644
index 0000000000..20345e4ff9
--- /dev/null
+++ b/packages/backend-test-utils/src/filesystem/MockDirectory.ts
@@ -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;
+}
diff --git a/packages/backend-test-utils/src/filesystem/index.ts b/packages/backend-test-utils/src/filesystem/index.ts
new file mode 100644
index 0000000000..0a4d8c7d00
--- /dev/null
+++ b/packages/backend-test-utils/src/filesystem/index.ts
@@ -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';
diff --git a/packages/backend-test-utils/src/index.ts b/packages/backend-test-utils/src/index.ts
index ae937d2fc8..ff1f2ee460 100644
--- a/packages/backend-test-utils/src/index.ts
+++ b/packages/backend-test-utils/src/index.ts
@@ -22,5 +22,6 @@
export * from './database';
export * from './msw';
+export * from './filesystem';
export * from './next';
export * from './util';
diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts
index 165d7ce6b3..a0735e3ba0 100644
--- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts
+++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts
@@ -320,7 +320,9 @@ export function createOAuthRouteHandlers(
providerInfo: {
idToken: result.session.idToken,
accessToken: result.session.accessToken,
- scope: result.session.scope,
+ scope: authenticator.shouldPersistScopes
+ ? scope
+ : result.session.scope,
expiresInSeconds: result.session.expiresInSeconds,
},
};
diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts
index 85a4326d60..c676cc6246 100644
--- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts
+++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts
@@ -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,
+ [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 () => {
diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts
index f3766d8513..9ac1bf6e5d 100644
--- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts
+++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts
@@ -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;
}
diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts
index 327d580d10..27d57cd854 100644
--- a/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts
+++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.test.ts
@@ -509,6 +509,134 @@ describe('KubernetesProxy', () => {
});
});
+ it('should invoke AuthStrategy if Backstage-Kubernetes-Authorization-X-X are provided', async () => {
+ const strategy: jest.Mocked = {
+ 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(),
diff --git a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts
index d2a3649c27..8a07267cde 100644
--- a/plugins/kubernetes-backend/src/service/KubernetesProxy.ts
+++ b/plugins/kubernetes-backend/src/service/KubernetesProxy.ts
@@ -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;
+ }
}
diff --git a/plugins/kubernetes-cluster/.eslintrc.js b/plugins/kubernetes-cluster/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/kubernetes-cluster/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/kubernetes-cluster/api-report.md b/plugins/kubernetes-cluster/api-report.md
new file mode 100644
index 0000000000..10d248097a
--- /dev/null
+++ b/plugins/kubernetes-cluster/api-report.md
@@ -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
+///
+
+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;
+```
diff --git a/plugins/kubernetes-cluster/catalog-info.yaml b/plugins/kubernetes-cluster/catalog-info.yaml
new file mode 100644
index 0000000000..3da5102e46
--- /dev/null
+++ b/plugins/kubernetes-cluster/catalog-info.yaml
@@ -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
diff --git a/plugins/kubernetes-cluster/package.json b/plugins/kubernetes-cluster/package.json
new file mode 100644
index 0000000000..2e1c8d8309
--- /dev/null
+++ b/plugins/kubernetes-cluster/package.json
@@ -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"
+ ]
+}
diff --git a/plugins/kubernetes-cluster/src/Router.tsx b/plugins/kubernetes-cluster/src/Router.tsx
new file mode 100644
index 0000000000..86563439ce
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/Router.tsx
@@ -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 (
+
+ } />
+
+ );
+ }
+
+ return (
+ <>
+
+ >
+ );
+};
diff --git a/plugins/kubernetes-cluster/src/components/ApiResources/ApiResources.test.tsx b/plugins/kubernetes-cluster/src/components/ApiResources/ApiResources.test.tsx
new file mode 100644
index 0000000000..601ba54a6d
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/components/ApiResources/ApiResources.test.tsx
@@ -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();
+
+ // Title
+ expect(getByText('Name')).toBeInTheDocument();
+ expect(getByText('Preferred Version')).toBeInTheDocument();
+
+ // Row 1
+ expect(getByText('some-apiVersion')).toBeInTheDocument();
+ expect(getByText('some-group-version')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/kubernetes-cluster/src/components/ApiResources/ApiResources.tsx b/plugins/kubernetes-cluster/src/components/ApiResources/ApiResources.tsx
new file mode 100644
index 0000000000..f6e8bc65ce
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/components/ApiResources/ApiResources.tsx
@@ -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[] = [
+ {
+ 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 (
+
+ {error !== undefined
+ ? 'Error loading API Resources'
+ : 'No API Resources found'}
+
+ }
+ columns={defaultColumns}
+ />
+ );
+};
diff --git a/plugins/kubernetes-cluster/src/components/ApiResources/useApiResources.ts b/plugins/kubernetes-cluster/src/components/ApiResources/useApiResources.ts
new file mode 100644
index 0000000000..b28b0b0b67
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/components/ApiResources/useApiResources.ts
@@ -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;
+ });
+ }, [clusterName]);
+};
diff --git a/plugins/kubernetes-cluster/src/components/ClusterOverview/ClusterOverview.test.tsx b/plugins/kubernetes-cluster/src/components/ClusterOverview/ClusterOverview.test.tsx
new file mode 100644
index 0000000000..6398093a74
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/components/ClusterOverview/ClusterOverview.test.tsx
@@ -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(
+ ,
+ );
+
+ 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);
+ });
+});
diff --git a/plugins/kubernetes-cluster/src/components/ClusterOverview/ClusterOverview.tsx b/plugins/kubernetes-cluster/src/components/ClusterOverview/ClusterOverview.tsx
new file mode 100644
index 0000000000..a4d4ffc40c
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/components/ClusterOverview/ClusterOverview.tsx
@@ -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 (
+
+ {!value && loading && (
+ <>
+
+ >
+ )}
+ {value && (
+
+ )}
+
+ );
+};
diff --git a/plugins/kubernetes-cluster/src/components/ClusterOverview/index.ts b/plugins/kubernetes-cluster/src/components/ClusterOverview/index.ts
new file mode 100644
index 0000000000..5a46125f3e
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/components/ClusterOverview/index.ts
@@ -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';
diff --git a/plugins/kubernetes-cluster/src/components/ClusterOverview/useCluster.ts b/plugins/kubernetes-cluster/src/components/ClusterOverview/useCluster.ts
new file mode 100644
index 0000000000..05106cdc5b
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/components/ClusterOverview/useCluster.ts
@@ -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]);
+};
diff --git a/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/KubernetesClusterContent.tsx b/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/KubernetesClusterContent.tsx
new file mode 100644
index 0000000000..665ca2acdd
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/KubernetesClusterContent.tsx
@@ -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 (
+ <>
+
+ {error && (
+
+
+ {error}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+/**
+ *
+ *
+ * @public
+ */
+export const KubernetesClusterContent = () => {
+ return (
+
+
+
+ );
+};
diff --git a/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/index.ts b/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/index.ts
new file mode 100644
index 0000000000..2f5850c10c
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/index.ts
@@ -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';
diff --git a/plugins/kubernetes-cluster/src/components/KubernetesClusterErrorContext/KubernetesClusterErrorContext.tsx b/plugins/kubernetes-cluster/src/components/KubernetesClusterErrorContext/KubernetesClusterErrorContext.tsx
new file mode 100644
index 0000000000..049a7b541d
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/components/KubernetesClusterErrorContext/KubernetesClusterErrorContext.tsx
@@ -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({
+ setError: (_: string) => {},
+});
+
+export interface KubernetesClusterErrorProviderProps {
+ children: React.ReactNode;
+}
+
+export const KubernetesClusterErrorProvider = ({
+ children,
+}: KubernetesClusterErrorProviderProps) => {
+ const [error, setError] = useState(undefined);
+
+ const contextValue: ErrorContext = {
+ error,
+ setError: useCallback((message: string) => setError(message), []),
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useKubernetesClusterError = () => {
+ return useContext(KubernetesClusterErrorContext);
+};
diff --git a/plugins/kubernetes-cluster/src/components/Nodes/Nodes.test.tsx b/plugins/kubernetes-cluster/src/components/Nodes/Nodes.test.tsx
new file mode 100644
index 0000000000..f48fdfbcef
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/components/Nodes/Nodes.test.tsx
@@ -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();
+
+ 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();
+ });
+});
diff --git a/plugins/kubernetes-cluster/src/components/Nodes/Nodes.tsx b/plugins/kubernetes-cluster/src/components/Nodes/Nodes.tsx
new file mode 100644
index 0000000000..e7f6e957ae
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/components/Nodes/Nodes.tsx
@@ -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[] = [
+ {
+ title: 'Name',
+ highlight: true,
+ width: 'auto',
+ render: (node: INode) => {
+ return (
+
+
+
+ Node Info
+
+
+
+ Addresses
+ {
+ accum[next.type] = next.address;
+ return accum;
+ }, {} as any) ?? {}
+ }
+ />
+
+
+ Taints
+ {
+ accum[`${next.effect}`] = `${next.key} (${next.value})`;
+ return accum;
+ }, {} as any) ?? {}
+ }
+ />
+
+
+
+ );
+ },
+ },
+ {
+ 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 (
+
+ {error !== undefined ? 'Error loading nodes' : 'No nodes found'}
+
+ }
+ columns={defaultColumns}
+ />
+ );
+};
diff --git a/plugins/kubernetes-cluster/src/components/Nodes/useNodes.ts b/plugins/kubernetes-cluster/src/components/Nodes/useNodes.ts
new file mode 100644
index 0000000000..1feaff3bdb
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/components/Nodes/useNodes.ts
@@ -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;
+ });
+ }, [clusterName]);
+};
diff --git a/plugins/kubernetes-cluster/src/index.ts b/plugins/kubernetes-cluster/src/index.ts
new file mode 100644
index 0000000000..6171b151e4
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/index.ts
@@ -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';
diff --git a/plugins/kubernetes-cluster/src/plugin.ts b/plugins/kubernetes-cluster/src/plugin.ts
new file mode 100644
index 0000000000..5cd9b2287c
--- /dev/null
+++ b/plugins/kubernetes-cluster/src/plugin.ts
@@ -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,
+ }),
+);
diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md
index 0840278394..8daeb9a875 100644
--- a/plugins/kubernetes-common/api-report.md
+++ b/plugins/kubernetes-common/api-report.md
@@ -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 {
diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts
index 9648293432..9f8d5eed82 100644
--- a/plugins/kubernetes-common/src/types.ts
+++ b/plugins/kubernetes-common/src/types.ts
@@ -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 {
diff --git a/plugins/kubernetes-react/api-report.md b/plugins/kubernetes-react/api-report.md
index e182524426..bb8e4923f6 100644
--- a/plugins/kubernetes-react/api-report.md
+++ b/plugins/kubernetes-react/api-report.md
@@ -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;
diff --git a/plugins/kubernetes-react/src/api/KubernetesBackendClient.test.ts b/plugins/kubernetes-react/src/api/KubernetesBackendClient.test.ts
index 7d2ef36602..fd999ab335 100644
--- a/plugins/kubernetes-react/src/api/KubernetesBackendClient.test.ts
+++ b/plugins/kubernetes-react/src/api/KubernetesBackendClient.test.ts
@@ -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),
),
diff --git a/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts b/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts
index b483c267d8..a769eb088d 100644
--- a/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts
+++ b/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts
@@ -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 {
- 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;
}
}
diff --git a/plugins/kubernetes-react/src/api/types.ts b/plugins/kubernetes-react/src/api/types.ts
index c0f97ffd41..28cf2dde55 100644
--- a/plugins/kubernetes-react/src/api/types.ts
+++ b/plugins/kubernetes-react/src/api/types.ts
@@ -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;
diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx
index 520f8802a9..fbc666ae13 100644
--- a/plugins/kubernetes/dev/index.tsx
+++ b/plugins/kubernetes/dev/index.tsx
@@ -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 {
return {
kind: 'Namespace',
diff --git a/yarn.lock b/yarn.lock
index b7987c9f3a..38af62fcd1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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