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