diff --git a/.changeset/catalog-compose-my-techdocs.md b/.changeset/catalog-compose-my-techdocs.md new file mode 100644 index 0000000000..d80e74eb36 --- /dev/null +++ b/.changeset/catalog-compose-my-techdocs.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-catalog': patch +--- + +**NOTE**: The entity `` now uses an external route ref to link to +TechDocs sites. This external route must now be bound in order for the "View +TechDocs" link to continue working. See the [create-app changelog][cacl] for +details. + +[cacl]: https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md diff --git a/.changeset/create-incerteza-desafinado-tomara.md b/.changeset/create-incerteza-desafinado-tomara.md new file mode 100644 index 0000000000..87e82d6b7a --- /dev/null +++ b/.changeset/create-incerteza-desafinado-tomara.md @@ -0,0 +1,28 @@ +--- +'@backstage/create-app': patch +--- + +Wire up TechDocs, which now relies on the composability API for routing. + +First, ensure you've mounted ``. If you already updated +to use the composable `` (see below), no action is +necessary. Otherwise, update your `App.tsx` so that `` is +mounted: + +```diff + } /> ++ } ++ /> +``` + +Next, ensure links from the Catalog Entity Page to its TechDocs site are bound: + +```diff + bindRoutes({ bind }) { + bind(catalogPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.root, ++ viewTechDoc: techdocsPlugin.routes.docRoot, + }); +``` diff --git a/.changeset/techdocs-all-done.md b/.changeset/techdocs-all-done.md new file mode 100644 index 0000000000..c4c1b5ed83 --- /dev/null +++ b/.changeset/techdocs-all-done.md @@ -0,0 +1,20 @@ +--- +'@backstage/plugin-techdocs': minor +'@backstage/plugin-techdocs-backend': minor +'@backstage/techdocs-common': minor +--- + +TechDocs sites can now be accessed using paths containing entity triplets of +any case (e.g. `/docs/namespace/KIND/name` or `/docs/namespace/kind/name`). + +If you do not use an external storage provider for serving TechDocs, this is a +transparent change and no action is required from you. + +If you _do_ use an external storage provider for serving TechDocs (one of\* GCS, +AWS S3, or Azure Blob Storage), you must run a migration command against your +storage provider before updating. + +[A migration guide is available here](https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-alpha-to-beta). + +- (\*) We're seeking help from the community to bring OpenStack Swift support + [to feature parity](https://github.com/backstage/backstage/issues/6763) with the above. diff --git a/.changeset/techdocs-jobim-gilberto-moraes.md b/.changeset/techdocs-jobim-gilberto-moraes.md new file mode 100644 index 0000000000..e5d23dd16a --- /dev/null +++ b/.changeset/techdocs-jobim-gilberto-moraes.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-techdocs': minor +--- + +The TechDocs plugin has completed the migration to the Composability API. In +order to update to this version, please ensure you've made all necessary +changes to your `App.tsx` file as outlined in the [create-app changelog][cacl]. + +[cacl]: https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index 6732b3ab18..bba05fa1ba 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -54,23 +54,12 @@ providers are used. | Google Cloud Storage (GCS) | Yes ✅ | | Amazon Web Services (AWS) S3 | Yes ✅ | | Azure Blob Storage | Yes ✅ | -| OpenStack Swift | Yes ✅ | +| OpenStack Swift | Community ✅ | [Reach out to us](#feedback) if you want to request more platforms. ## Project roadmap -### **Ongoing work 🚧** - -**Beta release** - -[Milestone](https://github.com/backstage/backstage/milestone/29) - -- It should be possible and easy to use TechDocs in most environments across - organizations. -- Minimal bugs, better error handling and scalable backend and frontend. -- Documentation Search -- TechDocs Homepage with basic features - ### **Future work 🔮** **General Availability (GA) release** - diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index 4bb6857106..46cbc2a8a2 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -113,6 +113,14 @@ techdocs: # https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json accountKey: ${TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY} + # (Optional and not recommended) Prior to version [0.x.y] of TechDocs, docs + # sites could only be accessed over paths with case-sensitive entity triplets + # e.g. (namespace/Kind/name). If you are upgrading from an older version of + # TechDocs and are unable to perform the necessary migration of files in your + # external storage, you can set this value to `true` to temporarily revert to + # the old, case-sensitive entity triplet behavior. + legacyUseCaseSensitiveTripletPaths: false + # (Optional and Legacy) TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. # You don't have to specify this anymore. diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index cf4bb9d005..0b1809dc22 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -33,14 +33,24 @@ In `packages/app/src/App.tsx`, import `TechDocsPage` and add the following to `FlatRoutes`: ```tsx -import { TechDocsPage } from '@backstage/plugin-techdocs'; +import { + DefaultTechDocsHome, + TechDocsIndexPage, + TechDocsReaderPage, +} from '@backstage/plugin-techdocs'; // ... const AppRoutes = () => { // ... other plugin routes - } /> + }> + + + } + /> ; }; ``` diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index e1533498d7..5fa14472d1 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -104,83 +104,110 @@ the repository. The archive does not have any git history attached to it. Also it is a compressed file. Hence the file size is significantly smaller than how much data git clone has to transfer. -## How to use a custom TechDocs home page? +## How to customize the TechDocs home page? -### 1st way: TechDocsCustomHome with a custom configuration +TechDocs uses a composability pattern similar to the Search and Catalog plugins +in Backstage. While a default table experience, similar to the one provided by +the Catalog plugin, is made available for ease-of-use, it's possible for you to +provide a completely custom experience, tailored to the needs of your +organization. -As an example, in your main App.tsx: +This is done in your `app` package. By default, you might see something like +this in your `App.tsx`: ```tsx -import { - TechDocsCustomHome, - PanelType, - TechDocsReaderPage, -} from '@backstage/plugin-techdocs'; -import { Entity } from '@backstage/catalog-model'; - -const tabsConfig = [ - { - label: 'Custom Tab', - panels: [ - { - title: 'Custom Documents Cards 1', - description: - 'Explore your internal technical ecosystem through documentation.', - panelType: 'DocsCardGrid' as PanelType, - // optional, is applied to a container of the panel (excludes header of panel) - panelCSS: { maxHeight: '400px', overflow:'auto' }, - filterPredicate: (entity: Entity) => !!entity.metadata.annotations?.['customCardAnnotationOne']; - }, - { - title: 'Custom Documents Cards 2', - description: - 'Explore your internal technical ecosystem through documentation.', - panelType: 'DocsCardGrid' as PanelType, - panelCSS: { maxHeight: '400px', overflow:'auto' }, - filterPredicate: (entity: Entity) => !!entity.metadata.annotations?.['customCardAnnotationTwo']; - }, - ], - }, - { - label: 'Overview', - panels: [ - { - title: 'Overview', - description: - 'Explore your internal technical ecosystem through documentation.', - panelType: 'DocsTable' as PanelType, - filterPredicate: () => true, - }, - ], - }, -]; - -const routes = ( +const AppRoutes = () => { - } - /> - } - /> - + }> + + + ; +}; ``` -An example of tabsConfig that corresponds to the default documentation home page -can be found at `plugins/techdocs/src/home/components/TechDocsHome.tsx`. +But you can replace `` with any React component, which +will be rendered in its place. Most likely, you would want to create and +maintain such a component in a new directory at +`packages/app/src/components/techdocs`, and import and use it in `App.tsx`: -Currently `panelType` has DocsCardGrid and DocsTable available. We currently -recommend that DocsCardGrid can be optionally vertically stacked by setting a -maxHeight using `panelCSS`, and DocsTable to be in a tab by itself. +```tsx +import { CustomTechDocsHome } from './components/techdocs/CustomTechDocsHome'; +// ... +const AppRoutes = () => { + + }> + + + ; +}; +``` -### 2nd way: Custom home page plugin +## How to migrate from TechDocs Alpha to Beta -A custom home page plugin can be built that uses the components extensions -DocsCardGrid and DocsTable, exported from @backstage/techdocs. They both take a -array of documentation entities ( i.e.have a 'backstage.io/techdocs-ref' -annotation ) as an 'entities' attribute. +> This guide only applies to the "recommended" TechDocs deployment method (where +> an external storage provider and external CI/CD is used). If you use the +> "basic" or "out-of-the-box" setup, you can stop here! No action needed. -For a reference to the React structure of the default home page, please refer to -`plugins/techdocs/src/home/components/TechDocsCustomHome.tsx`. +The beta version of TechDocs (v0.x.y) made a breaking change to the way TechDocs +content was accessed and stored, allowing pages to be accessed with +case-insensitive entity triplet paths (e.g. `/docs/namespace/kind/name` whereas +in prior versions, they could only be accessed at `/docs/namespace/Kind/name`). +In order to enable this change, documentation has to be stored in an external +storage provider using an object key whose entity triplet is lower-cased. + +New installations of TechDocs since the beta version will work fine with no +action, but for those who were running TechDocs prior to this version, a +migration will need to be performed so that all existing content in your storage +bucket matches this lower-case entity triplet expectation. + +1. **Ensure you have the right permissions on your storage provider**: In order + to migrate files in your storage provider, the `techdocs-cli` needs to be + able to read/copy/rename/move/delete files. The exact instructions vary by + storage provider, but check the [using cloud storage][using-cloud-storage] + page for details. + +2. **Run a non-destructive migration of files**: Ensure you have the latest + version of `techdocs-cli` installed. Then run the following command, using + the details relevant for your provider / configuration. This will copy all + files from, e.g. `namespace/Kind/name/index.html` to + `namespace/kind/name/index.html`, without removing the original files. + +```sh +techdocs-cli migrate --publisher-type --storage-name --verbose +``` + +3. **Deploy the updated versions of the TechDocs plugins**: Once the migration + above has been run, you can deploy the beta versions of the TechDocs backend + and frontend plugins to your Backstage instance. + +4. **Verify that your TechDocs sites are still loading/accessible**: Try + accessing a TechDocs site using different entity-triplet case variants, e.g. + `/docs/namespace/KIND/name` or `/docs/namespace/kind/name`. Your TechDocs + site should load regardless of the URL path casing you use. + +5. **Clean up the old objects from storage**: Once you've verified that your + TechDocs site is accessible, you can clean up your storage bucket by + re-running the `migrate` command on the TechDocs CLI, but with an additional + `removeOriginal` flag passed: + +```sh +techdocs-cli migrate --publisher-type --storage-name --removeOriginal --verbose +``` + +6. **Update your CI/CD pipelines to use the beta version of the TechDocs CLI**: + Finally, you can update all of your CI/CD pipelines to use at least v0.x.y of + the TechDocs CLI, ensuring that all sites are published to the new, + lower-cased entity triplet paths going forward. + +If you encounter problems running this migration, please [report the +issue][beta-migrate-bug]. You can temporarily revert to pre-beta storage +expectations with a configuration change: + +```yaml +techdocs: + legacyUseCaseSensitiveTripletPaths: true +``` + +[beta-migrate-bug]: +https://github.com/backstage/backstage/issues/new?assignees=&labels=bug&template=bug_template.md&title=[TechDocs]%20Unable%20to%20run%20beta%20migration +[using-cloud-storage]: ./using-cloud-storage.md diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index a73039fed2..81dbdf4cac 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -52,6 +52,7 @@ import { TechRadarPage } from '@backstage/plugin-tech-radar'; import { DefaultTechDocsHome, TechDocsIndexPage, + techdocsPlugin, TechDocsReaderPage, } from '@backstage/plugin-techdocs'; import { UserSettingsPage } from '@backstage/plugin-user-settings'; @@ -92,6 +93,7 @@ const app = createApp({ bindRoutes({ bind }) { bind(catalogPlugin.externalRoutes, { createComponent: scaffolderPlugin.routes.root, + viewTechDoc: techdocsPlugin.routes.docRoot, }); bind(apiDocsPlugin.externalRoutes, { createComponent: scaffolderPlugin.routes.root, diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx index 57491c9222..288351ccdf 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.tsx @@ -16,6 +16,7 @@ import { TechRadarPage } from '@backstage/plugin-tech-radar'; import { DefaultTechDocsHome, TechDocsIndexPage, + techdocsPlugin, TechDocsReaderPage, } from '@backstage/plugin-techdocs'; import { UserSettingsPage } from '@backstage/plugin-user-settings'; @@ -31,6 +32,7 @@ const app = createApp({ bindRoutes({ bind }) { bind(catalogPlugin.externalRoutes, { createComponent: scaffolderPlugin.routes.root, + viewTechDoc: techdocsPlugin.routes.docRoot, }); bind(apiDocsPlugin.externalRoutes, { createComponent: scaffolderPlugin.routes.root, diff --git a/packages/techdocs-common/__mocks__/@azure/storage-blob.ts b/packages/techdocs-common/__mocks__/@azure/storage-blob.ts index 7279fc31eb..3af2dc4a30 100644 --- a/packages/techdocs-common/__mocks__/@azure/storage-blob.ts +++ b/packages/techdocs-common/__mocks__/@azure/storage-blob.ts @@ -13,34 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { +import { BlobUploadCommonResponse, ContainerGetPropertiesResponse, } from '@azure/storage-blob'; import { EventEmitter } from 'events'; -import fs from 'fs-extra'; -import os from 'os'; -import path from 'path'; -const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; - -/** - * @param sourceFile Relative path to entity root dir. Contains either / or \ as file separator - * depending upon the OS. - */ -const checkFileExists = async (sourceFile: string): Promise => { - // sourceFile will always have / as file separator irrespective of OS since Azure expects /. - // Normalize sourceFile to OS specific path before checking if file exists. - const relativeFilePath = sourceFile.split(path.posix.sep).join(path.sep); - const filePath = path.join(rootDir, sourceFile); - - try { - await fs.access(filePath, fs.constants.F_OK); - return true; - } catch (err) { - return false; - } -}; +const storage = global.storageFilesMock; export class BlockBlobClient { private readonly blobName; @@ -50,9 +29,7 @@ export class BlockBlobClient { } uploadFile(source: string): Promise { - if (!fs.existsSync(source)) { - return Promise.reject(new Error(`The file ${source} does not exist`)); - } + storage.writeFile(this.blobName, source); return Promise.resolve({ _response: { request: { @@ -65,20 +42,19 @@ export class BlockBlobClient { } exists() { - return checkFileExists(this.blobName); + return storage.fileExists(this.blobName); } download() { - const filePath = path.join(rootDir, this.blobName); const emitter = new EventEmitter(); setTimeout(() => { - if (fs.existsSync(filePath)) { - emitter.emit('data', fs.readFileSync(filePath)); + if (storage.fileExists(this.blobName)) { + emitter.emit('data', storage.readFile(this.blobName)); emitter.emit('end'); } else { emitter.emit( 'error', - new Error(`The file ${filePath} does not exist !`), + new Error(`The file ${this.blobName} does not exist!`), ); } }, 0); @@ -89,7 +65,7 @@ export class BlockBlobClient { } class BlockBlobClientFailUpload extends BlockBlobClient { - uploadFile(source: string): Promise { + uploadFile(): Promise { return Promise.resolve({ _response: { request: { @@ -133,12 +109,6 @@ class ContainerClientIterator { } export class ContainerClient { - private readonly containerName; - - constructor(containerName: string) { - this.containerName = containerName; - } - getProperties(): Promise { return Promise.resolve({ _response: { @@ -197,18 +167,19 @@ export class BlobServiceClient { private readonly credential; constructor(url: string, credential?: StorageSharedKeyCredential) { + storage.emptyFiles(); this.url = url; this.credential = credential; } getContainerClient(containerName: string) { if (containerName === 'bad_container') { - return new ContainerClientFailGetProperties(containerName); + return new ContainerClientFailGetProperties(); } - if (this.credential.accountName === 'failupload') { - return new ContainerClientFailUpload(containerName); + if (this.credential.accountName === 'bad_account_credentials') { + return new ContainerClientFailUpload(); } - return new ContainerClient(containerName); + return new ContainerClient(); } } diff --git a/packages/techdocs-common/__mocks__/@google-cloud/storage.ts b/packages/techdocs-common/__mocks__/@google-cloud/storage.ts index f5616236cf..a48e8c1273 100644 --- a/packages/techdocs-common/__mocks__/@google-cloud/storage.ts +++ b/packages/techdocs-common/__mocks__/@google-cloud/storage.ts @@ -14,42 +14,19 @@ * limitations under the License. */ import { Readable } from 'stream'; -import fs from 'fs-extra'; -import os from 'os'; -import path from 'path'; -type storageOptions = { - keyFilename?: string; -}; - -const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; -/** - * @param sourceFile Absolute path. Contains either / or \ as file separator depending upon the OS. - */ -const checkFileExists = async (sourceFile: string): Promise => { - // sourceFile will always have / as file separator irrespective of OS since GCS expects /. - // Normalize sourceFile to OS specific path before checking if file exists. - const filePath = sourceFile.split(path.posix.sep).join(path.sep); - - try { - await fs.access(filePath, fs.constants.F_OK); - return true; - } catch (err) { - return false; - } -}; +const storage = global.storageFilesMock; class GCSFile { - private readonly localFilePath: string; + private readonly path: string; - constructor(private readonly destinationFilePath: string) { - this.destinationFilePath = destinationFilePath; - this.localFilePath = path.join(rootDir, this.destinationFilePath); + constructor(path: string) { + this.path = path; } exists() { return new Promise(async (resolve, reject) => { - if (await checkFileExists(this.localFilePath)) { + if (storage.fileExists(this.path)) { resolve([true]); } else { reject(); @@ -62,19 +39,20 @@ class GCSFile { readable._read = () => {}; process.nextTick(() => { - if (fs.existsSync(this.localFilePath)) { + if (storage.fileExists(this.path)) { if (readable.eventNames().includes('pipe')) { readable.emit('pipe'); } - readable.emit('data', fs.readFileSync(this.localFilePath)); + readable.emit('data', storage.readFile(this.path)); readable.emit('end'); } else { readable.emit( 'error', - new Error(`The file ${this.localFilePath} does not exist !`), + new Error(`The file ${this.path} does not exist!`), ); } }); + return readable; } @@ -91,20 +69,16 @@ class Bucket { } async getMetadata() { - if (this.bucketName === 'errorBucket') { + if (this.bucketName === 'bad_bucket_name') { throw Error('Bucket does not exist'); } - return ''; } upload(source: string, { destination }) { - return new Promise(async (resolve, reject) => { - if (await checkFileExists(source)) { - resolve({ source, destination }); - } else { - reject(`Source file ${source} does not exist.`); - } + return new Promise(async resolve => { + storage.writeFile(destination, source); + resolve(null); }); } @@ -134,10 +108,8 @@ class Bucket { } export class Storage { - private readonly keyFilename; - - constructor(options: storageOptions) { - this.keyFilename = options.keyFilename; + constructor() { + storage.emptyFiles(); } bucket(bucketName) { diff --git a/packages/techdocs-common/__mocks__/aws-sdk.ts b/packages/techdocs-common/__mocks__/aws-sdk.ts index 9b3218f6dc..e4ae44c94e 100644 --- a/packages/techdocs-common/__mocks__/aws-sdk.ts +++ b/packages/techdocs-common/__mocks__/aws-sdk.ts @@ -13,45 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { S3 as S3Types } from 'aws-sdk'; import { EventEmitter } from 'events'; -import fs from 'fs-extra'; -import os from 'os'; -import path from 'path'; +import { ReadStream } from 'fs'; export { Credentials } from 'aws-sdk'; -const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; - -/** - * @param Key Relative path to entity root dir. Contains either / or \ as file separator - * depending upon the OS. - */ -const checkFileExists = async (Key: string): Promise => { - // Key will always have / as file separator irrespective of OS since S3 expects /. - // Normalize Key to OS specific path before checking if file exists. - const relativeFilePath = Key.split(path.posix.sep).join(path.sep); - const filePath = path.join(rootDir, Key); - - try { - await fs.access(filePath, fs.constants.F_OK); - return true; - } catch (err) { - return false; - } -}; +const storage = global.storageFilesMock; export class S3 { - private readonly options; - - constructor(options: S3Types.ClientConfiguration) { - this.options = options; + constructor() { + storage.emptyFiles(); } headObject({ Key }: { Key: string }) { return { promise: async () => { - if (!(await checkFileExists(Key))) { + if (!storage.fileExists(Key)) { throw new Error('File does not exist'); } }, @@ -59,20 +36,16 @@ export class S3 { } getObject({ Key }: { Key: string }) { - const filePath = path.join(rootDir, Key); return { - promise: () => checkFileExists(filePath), + promise: async () => storage.fileExists(Key), createReadStream: () => { const emitter = new EventEmitter(); process.nextTick(() => { - if (fs.existsSync(filePath)) { - emitter.emit('data', Buffer.from(fs.readFileSync(filePath))); + if (storage.fileExists(Key)) { + emitter.emit('data', Buffer.from(storage.readFile(Key))); emitter.emit('end'); } else { - emitter.emit( - 'error', - new Error(`The file ${filePath} does not exist !`), - ); + emitter.emit('error', new Error(`The file ${Key} does not exist!`)); } }); return emitter; @@ -86,21 +59,23 @@ export class S3 { if (Bucket === 'errorBucket') { throw new Error('Bucket does not exist'); } - return {}; }, }; } - upload({ Key }: { Key: string }) { + upload({ Key, Body }: { Key: string; Body: ReadStream }) { return { promise: () => - new Promise(async (resolve, reject) => { - if (!(await checkFileExists(Key))) { - reject(`The file ${Key} does not exist`); - } else { - resolve(''); - } + new Promise(async resolve => { + const chunks = []; + Body.on('data', chunk => { + chunks.push(chunk); + }); + Body.once('end', () => { + storage.writeFile(Key, Buffer.concat(chunks)); + resolve(null); + }); }), }; } diff --git a/packages/techdocs-common/__mocks__/globals.d.ts b/packages/techdocs-common/__mocks__/globals.d.ts new file mode 100644 index 0000000000..ad293d2ebe --- /dev/null +++ b/packages/techdocs-common/__mocks__/globals.d.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +declare module NodeJS { + interface Global { + rootDir: string; + storageFilesMock: IStorageFilesMock; + } +} diff --git a/packages/techdocs-common/__mocks__/types.d.ts b/packages/techdocs-common/__mocks__/types.d.ts new file mode 100644 index 0000000000..e085b95851 --- /dev/null +++ b/packages/techdocs-common/__mocks__/types.d.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +interface IStorageFilesMock { + emptyFiles(): void; + fileExists(targetPath: string): boolean; + readFile(targetPath: string): Buffer; + writeFile(targetPath: string, sourcePath: string): void; + writeFile(targetPath: string, sourceBuffer: Buffer): void; + writeFile(targetPath: string, source: string | Buffer): void; +} diff --git a/packages/techdocs-common/src/globals.d.ts b/packages/techdocs-common/src/globals.d.ts new file mode 100644 index 0000000000..ad293d2ebe --- /dev/null +++ b/packages/techdocs-common/src/globals.d.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +declare module NodeJS { + interface Global { + rootDir: string; + storageFilesMock: IStorageFilesMock; + } +} diff --git a/packages/techdocs-common/src/setupTests.ts b/packages/techdocs-common/src/setupTests.ts new file mode 100644 index 0000000000..9a13e57633 --- /dev/null +++ b/packages/techdocs-common/src/setupTests.ts @@ -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 os from 'os'; +import path from 'path'; +import fs from 'fs-extra'; + +const rootDir: string = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; + +const encoding = 'utf8'; + +class StorageFilesMock implements IStorageFilesMock { + private files: Record; + + constructor() { + this.files = {}; + } + + public emptyFiles(): void { + this.files = {}; + } + + public fileExists(targetPath: string): boolean { + const filePath = path.join(rootDir, targetPath); + const posixPath = filePath.split(path.posix.sep).join(path.sep); + return this.files[posixPath] !== undefined; + } + + public readFile(targetPath: string): Buffer { + const filePath = path.join(rootDir, targetPath); + return Buffer.from(this.files[filePath] ?? '', encoding); + } + + public writeFile(targetPath: string, sourcePath: string): void; + public writeFile(targetPath: string, sourceBuffer: Buffer): void; + public writeFile(targetPath: string, source: string | Buffer): void { + const filePath = path.join(rootDir, targetPath); + if (typeof source === 'string') { + this.files[filePath] = fs.readFileSync(source).toString(encoding); + } else { + this.files[filePath] = source.toString(encoding); + } + } +} + +global.rootDir = rootDir; +global.storageFilesMock = new StorageFilesMock(); diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts index b94518c857..028be87e33 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -15,43 +15,18 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { - Entity, - EntityName, - ENTITY_DEFAULT_NAMESPACE, -} from '@backstage/catalog-model'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import express from 'express'; import request from 'supertest'; import mockFs from 'mock-fs'; -import os from 'os'; import path from 'path'; +import fs from 'fs-extra'; import { AwsS3Publish } from './awsS3'; -import { PublisherBase, TechDocsMetadata } from './types'; // NOTE: /packages/techdocs-common/__mocks__ is being used to mock aws-sdk client library -const createMockEntity = (annotations = {}): Entity => { - return { - apiVersion: 'version', - kind: 'TestKind', - metadata: { - name: 'test-component-name', - namespace: 'test-namespace', - annotations: { - ...annotations, - }, - }, - }; -}; - -const createMockEntityName = (): EntityName => ({ - kind: 'TestKind', - name: 'test-component-name', - namespace: 'test-namespace', -}); - -const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; +const rootDir = global.rootDir; const getEntityRootDir = (entity: Entity) => { const { @@ -66,7 +41,13 @@ const logger = getVoidLogger(); const loggerInfoSpy = jest.spyOn(logger, 'info'); const loggerErrorSpy = jest.spyOn(logger, 'error'); -const createPublisherMock = (bucketName: string) => { +const createPublisherFromConfig = ({ + bucketName = 'bucketName', + legacyUseCaseSensitiveTripletPaths = false, +}: { + bucketName?: string; + legacyUseCaseSensitiveTripletPaths?: boolean; +} = {}) => { const mockConfig = new ConfigReader({ techdocs: { requestUrl: 'http://localhost:7000', @@ -80,71 +61,96 @@ const createPublisherMock = (bucketName: string) => { bucketName, }, }, + legacyUseCaseSensitiveTripletPaths, }, }); return AwsS3Publish.fromConfig(mockConfig, logger); }; -let publisher: PublisherBase; - -beforeEach(() => { - loggerInfoSpy.mockClear(); - loggerErrorSpy.mockClear(); - mockFs.restore(); - publisher = createPublisherMock('bucketName'); -}); - describe('AwsS3Publish', () => { + const entity = { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'backstage', + namespace: 'default', + annotations: {}, + }, + }; + + const entityName = { + kind: 'Component', + name: 'backstage', + namespace: 'default', + }; + + const techdocsMetadata = { + site_name: 'backstage', + site_description: 'site_content', + etag: 'etag', + }; + + const directory = getEntityRootDir(entity); + + const files = { + 'index.html': '', + '404.html': '', + 'techdocs_metadata.json': JSON.stringify(techdocsMetadata), + assets: { + 'main.css': '', + }, + html: { + 'unsafe.html': '', + }, + img: { + 'with spaces.png': 'found it', + 'unsafe.svg': '', + }, + 'some folder': { + 'also with spaces.js': 'found it too', + }, + }; + + beforeEach(() => { + mockFs({ + [directory]: files, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + describe('getReadiness', () => { it('should validate correct config', async () => { + const publisher = createPublisherFromConfig(); expect(await publisher.getReadiness()).toEqual({ isAvailable: true, }); }); it('should reject incorrect config', async () => { - const errorPublisher = createPublisherMock('errorBucket'); - - expect(await errorPublisher.getReadiness()).toEqual({ + const publisher = createPublisherFromConfig({ + bucketName: 'errorBucket', + }); + expect(await publisher.getReadiness()).toEqual({ isAvailable: false, }); }); }); describe('publish', () => { - beforeEach(() => { - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - - mockFs({ - [entityRootDir]: { - 'index.html': '', - '404.html': '', - assets: { - 'main.css': '', - }, - attachments: { - 'image.png': Buffer.from([2, 3, 5, 3, 5, 3, 5, 9, 1]), - }, - }, - }); - }); - - afterEach(() => { - mockFs.restore(); - }); - it('should publish a directory', async () => { - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); + const publisher = createPublisherFromConfig(); + expect(await publisher.publish({ entity, directory })).toBeUndefined(); + }); - expect( - await publisher.publish({ - entity, - directory: entityRootDir, - }), - ).toBeUndefined(); + it('should publish a directory as well when legacy casing is used', async () => { + const publisher = createPublisherFromConfig({ + legacyUseCaseSensitiveTripletPaths: true, + }); + expect(await publisher.publish({ entity, directory })).toBeUndefined(); }); it('should fail to publish a directory', async () => { @@ -156,36 +162,27 @@ describe('AwsS3Publish', () => { 'generatedDirectory', ); - const entity = createMockEntity(); - await expect( - publisher.publish({ - entity, - directory: wrongPathToGeneratedDirectory, - }), - ).rejects.toThrowError(); + const publisher = createPublisherFromConfig(); const fails = publisher.publish({ entity, directory: wrongPathToGeneratedDirectory, }); - // Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error - // Issue reported https://github.com/tschaub/mock-fs/issues/118 await expect(fails).rejects.toMatchObject({ message: expect.stringContaining( 'Unable to upload file(s) to AWS S3. Error: Failed to read template directory: ENOENT, no such file or directory', ), }); + await expect(fails).rejects.toMatchObject({ message: expect.stringContaining(wrongPathToGeneratedDirectory), }); }); it('should delete stale files after upload', async () => { - const entity = createMockEntity(); - const directory = getEntityRootDir(entity); const bucketName = 'delete_stale_files_success'; - publisher = createPublisherMock(bucketName); + const publisher = createPublisherFromConfig({ bucketName: bucketName }); await publisher.publish({ entity, directory }); expect(loggerInfoSpy).toHaveBeenLastCalledWith( `Successfully deleted stale files for Entity ${entity.metadata.name}. Total number of files: 1`, @@ -193,10 +190,8 @@ describe('AwsS3Publish', () => { }); it('should log error when the stale files deletion fails', async () => { - const entity = createMockEntity(); - const directory = getEntityRootDir(entity); const bucketName = 'delete_stale_files_error'; - publisher = createPublisherMock(bucketName); + const publisher = createPublisherFromConfig({ bucketName: bucketName }); await publisher.publish({ entity, directory }); expect(loggerErrorSpy).toHaveBeenLastCalledWith( 'Unable to delete file(s) from AWS S3. Error: Message', @@ -206,142 +201,143 @@ describe('AwsS3Publish', () => { describe('hasDocsBeenGenerated', () => { it('should return true if docs has been generated', async () => { - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - - mockFs({ - [entityRootDir]: { - 'index.html': 'file-content', - }, - }); - + const publisher = createPublisherFromConfig(); + await publisher.publish({ entity, directory }); + expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true); + }); + + it('should return true if docs has been generated even if the legacy case is enabled', async () => { + const publisher = createPublisherFromConfig({ + legacyUseCaseSensitiveTripletPaths: true, + }); + await publisher.publish({ entity, directory }); expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true); - mockFs.restore(); }); it('should return false if docs has not been generated', async () => { - const entity = createMockEntity(); - - expect(await publisher.hasDocsBeenGenerated(entity)).toBe(false); + const publisher = createPublisherFromConfig(); + expect( + await publisher.hasDocsBeenGenerated({ + kind: 'entity', + metadata: { + namespace: 'invalid', + name: 'triplet', + }, + } as Entity), + ).toBe(false); }); }); describe('fetchTechDocsMetadata', () => { it('should return tech docs metadata', async () => { - const entityNameMock = createMockEntityName(); - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); + const publisher = createPublisherFromConfig(); + await publisher.publish({ entity, directory }); + expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual( + techdocsMetadata, + ); + }); - mockFs({ - [entityRootDir]: { - 'techdocs_metadata.json': - '{"site_name": "backstage", "site_description": "site_content", "etag": "etag"}', - }, + it('should return tech docs metadata even if the legacy case is enabled', async () => { + const publisher = createPublisherFromConfig({ + legacyUseCaseSensitiveTripletPaths: true, }); - - const expectedMetadata: TechDocsMetadata = { - site_name: 'backstage', - site_description: 'site_content', - etag: 'etag', - }; - expect( - await publisher.fetchTechDocsMetadata(entityNameMock), - ).toStrictEqual(expectedMetadata); - mockFs.restore(); + await publisher.publish({ entity, directory }); + expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual( + techdocsMetadata, + ); }); it('should return tech docs metadata when json encoded with single quotes', async () => { - const entityNameMock = createMockEntityName(); - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); + const techdocsMetadataPath = path.join( + directory, + 'techdocs_metadata.json', + ); + const techdocsMetadataContent = files['techdocs_metadata.json']; - mockFs({ - [entityRootDir]: { - 'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag'}`, - }, - }); + fs.writeFileSync( + techdocsMetadataPath, + techdocsMetadataContent.replace(/"/g, "'"), + ); - const expectedMetadata: TechDocsMetadata = { - site_name: 'backstage', - site_description: 'site_content', - etag: 'etag', - }; - expect( - await publisher.fetchTechDocsMetadata(entityNameMock), - ).toStrictEqual(expectedMetadata); - mockFs.restore(); + const publisher = createPublisherFromConfig(); + await publisher.publish({ entity, directory }); + + expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual( + techdocsMetadata, + ); + + fs.writeFileSync(techdocsMetadataPath, techdocsMetadataContent); }); it('should return an error if the techdocs_metadata.json file is not present', async () => { - const entityNameMock = createMockEntityName(); - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); + const publisher = createPublisherFromConfig(); - const fails = publisher.fetchTechDocsMetadata(entityNameMock); + const invalidEntityName = { + namespace: 'invalid', + kind: 'triplet', + name: 'path', + }; + + const techDocsMetadaFilePath = path.join( + ...Object.values(invalidEntityName), + 'techdocs_metadata.json', + ); + + const fails = publisher.fetchTechDocsMetadata(invalidEntityName); - const errorPath = path.join(entityRootDir, 'techdocs_metadata.json'); await expect(fails).rejects.toMatchObject({ - message: `TechDocs metadata fetch failed, The file ${errorPath} does not exist !`, + message: `TechDocs metadata fetch failed, The file ${techDocsMetadaFilePath} does not exist!`, }); }); }); describe('docsRouter', () => { + const entityTripletPath = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + let app: express.Express; - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - beforeEach(() => { + beforeEach(async () => { + const publisher = createPublisherFromConfig(); + await publisher.publish({ entity, directory }); app = express().use(publisher.docsRouter()); - - mockFs.restore(); - mockFs({ - [entityRootDir]: { - html: { - 'unsafe.html': '', - }, - img: { - 'with spaces.png': 'found it', - 'unsafe.svg': '', - }, - 'some folder': { - 'also with spaces.js': 'found it too', - }, - }, - }); - }); - - afterEach(() => { - mockFs.restore(); }); it('should pass expected object path to bucket', async () => { - const { - kind, - metadata: { namespace, name }, - } = entity; - // Ensures leading slash is trimmed and encoded path is decoded. const pngResponse = await request(app).get( - `/${namespace}/${kind}/${name}/img/with%20spaces.png`, + `/${entityTripletPath}/img/with%20spaces.png`, ); expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual( 'found it', ); const jsResponse = await request(app).get( - `/${namespace}/${kind}/${name}/some%20folder/also%20with%20spaces.js`, + `/${entityTripletPath}/some%20folder/also%20with%20spaces.js`, + ); + expect(jsResponse.text).toEqual('found it too'); + }); + + it('should pass expected object path to bucket even if the legacy case is enabled', async () => { + const publisher = createPublisherFromConfig({ + legacyUseCaseSensitiveTripletPaths: true, + }); + await publisher.publish({ entity, directory }); + app = express().use(publisher.docsRouter()); + // Ensures leading slash is trimmed and encoded path is decoded. + const pngResponse = await request(app).get( + `/${entityTripletPath}/img/with%20spaces.png`, + ); + expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual( + 'found it', + ); + const jsResponse = await request(app).get( + `/${entityTripletPath}/some%20folder/also%20with%20spaces.js`, ); expect(jsResponse.text).toEqual('found it too'); }); it('should pass text/plain content-type for html', async () => { - const { - kind, - metadata: { namespace, name }, - } = entity; - const htmlResponse = await request(app).get( - `/${namespace}/${kind}/${name}/html/unsafe.html`, + `/${entityTripletPath}/html/unsafe.html`, ); expect(htmlResponse.text).toEqual(''); expect(htmlResponse.header).toMatchObject({ @@ -349,7 +345,7 @@ describe('AwsS3Publish', () => { }); const svgResponse = await request(app).get( - `/${namespace}/${kind}/${name}/img/unsafe.svg`, + `/${entityTripletPath}/img/unsafe.svg`, ); expect(svgResponse.text).toEqual(''); expect(svgResponse.header).toMatchObject({ diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index 02d4f7226f..85857929d5 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -31,6 +31,7 @@ import { getFileTreeRecursively, getHeadersForFileExtension, getStaleFiles, + lowerCaseEntityTriplet, lowerCaseEntityTripletInStoragePath, } from './helpers'; import { @@ -100,7 +101,17 @@ export class AwsS3Publish implements PublisherBase { ...(s3ForcePathStyle && { s3ForcePathStyle }), }); - return new AwsS3Publish(storageClient, bucketName, logger); + const legacyPathCasing = + config.getOptionalBoolean( + 'techdocs.legacyUseCaseSensitiveTripletPaths', + ) || false; + + return new AwsS3Publish( + storageClient, + bucketName, + legacyPathCasing, + logger, + ); } private static buildCredentials( @@ -137,10 +148,12 @@ export class AwsS3Publish implements PublisherBase { constructor( private readonly storageClient: aws.S3, private readonly bucketName: string, + private readonly legacyPathCasing: boolean, private readonly logger: Logger, ) { this.storageClient = storageClient; this.bucketName = bucketName; + this.legacyPathCasing = legacyPathCasing; this.logger = logger; } @@ -178,10 +191,15 @@ export class AwsS3Publish implements PublisherBase { * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html */ async publish({ entity, directory }: PublishRequest): Promise { + const useLegacyPathCasing = this.legacyPathCasing; // First, try to retrieve a list of all individual files currently existing let existingFiles: string[] = []; try { - const remoteFolder = getCloudPathForLocalPath(entity); + const remoteFolder = getCloudPathForLocalPath( + entity, + undefined, + useLegacyPathCasing, + ); existingFiles = await this.getAllObjectsFromBucket({ prefix: remoteFolder, }); @@ -206,7 +224,11 @@ export class AwsS3Publish implements PublisherBase { const params = { Bucket: this.bucketName, - Key: getCloudPathForLocalPath(entity, relativeFilePath), + Key: getCloudPathForLocalPath( + entity, + relativeFilePath, + useLegacyPathCasing, + ), Body: fileStream, }; @@ -232,6 +254,7 @@ export class AwsS3Publish implements PublisherBase { getCloudPathForLocalPath( entity, path.relative(directory, absoluteFilePath), + useLegacyPathCasing, ), ); const staleFiles = getStaleFiles(relativeFilesToUpload, existingFiles); @@ -263,7 +286,10 @@ export class AwsS3Publish implements PublisherBase { ): Promise { try { return await new Promise(async (resolve, reject) => { - const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + const entityTriplet = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + const entityRootDir = this.legacyPathCasing + ? entityTriplet + : lowerCaseEntityTriplet(entityTriplet); const stream = this.storageClient .getObject({ @@ -301,8 +327,12 @@ export class AwsS3Publish implements PublisherBase { docsRouter(): express.Handler { return async (req, res) => { // Decode and trim the leading forward slash - // filePath example - /default/Component/documented-component/index.html - const filePath = decodeURI(req.path.replace(/^\//, '')); + const decodedUri = decodeURI(req.path.replace(/^\//, '')); + + // filePath example - /default/component/documented-component/index.html + const filePath = this.legacyPathCasing + ? decodedUri + : lowerCaseEntityTripletInStoragePath(decodedUri); // Files with different extensions (CSS, HTML) need to be served with different headers const fileExtension = path.extname(filePath); @@ -333,7 +363,11 @@ export class AwsS3Publish implements PublisherBase { */ async hasDocsBeenGenerated(entity: Entity): Promise { try { - const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + const entityTriplet = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + const entityRootDir = this.legacyPathCasing + ? entityTriplet + : lowerCaseEntityTriplet(entityTriplet); + await this.storageClient .headObject({ Bucket: this.bucketName, diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index 94f960986c..200e6a3054 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -15,43 +15,18 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { - Entity, - EntityName, - ENTITY_DEFAULT_NAMESPACE, -} from '@backstage/catalog-model'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import express from 'express'; import request from 'supertest'; import mockFs from 'mock-fs'; -import os from 'os'; import path from 'path'; +import fs from 'fs-extra'; import { AzureBlobStoragePublish } from './azureBlobStorage'; -import { PublisherBase, TechDocsMetadata } from './types'; // NOTE: /packages/techdocs-common/__mocks__ is being used to mock Azure client library -const createMockEntity = (annotations = {}) => { - return { - apiVersion: 'version', - kind: 'TestKind', - metadata: { - name: 'test-component-name', - namespace: 'test-namespace', - annotations: { - ...annotations, - }, - }, - }; -}; - -const createMockEntityName = (): EntityName => ({ - kind: 'TestKind', - name: 'test-component-name', - namespace: 'test-namespace', -}); - -const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; +const rootDir = global.rootDir; const getEntityRootDir = (entity: Entity) => { const { @@ -63,19 +38,18 @@ const getEntityRootDir = (entity: Entity) => { }; const logger = getVoidLogger(); -const loggerInfoSpy = jest.spyOn(logger, 'info'); -const loggerErrorSpy = jest.spyOn(logger, 'error'); +jest.spyOn(logger, 'error').mockReturnValue(logger); -const createPublisherMock = ({ +const createPublisherFromConfig = ({ accountName = 'accountName', containerName = 'containerName', -}: - | { - accountName?: string; - containerName?: string; - } - | undefined = {}) => { - const mockConfig = new ConfigReader({ + legacyUseCaseSensitiveTripletPaths = false, +}: { + accountName?: string; + containerName?: string; + legacyUseCaseSensitiveTripletPaths?: boolean; +} = {}) => { + const config = new ConfigReader({ techdocs: { requestUrl: 'http://localhost:7000', publisher: { @@ -88,30 +62,80 @@ const createPublisherMock = ({ containerName, }, }, + legacyUseCaseSensitiveTripletPaths, }, }); - - return AzureBlobStoragePublish.fromConfig(mockConfig, logger); + return AzureBlobStoragePublish.fromConfig(config, logger); }; -let publisher: PublisherBase; -beforeEach(async () => { - loggerInfoSpy.mockClear(); - loggerErrorSpy.mockClear(); - mockFs.restore(); - publisher = createPublisherMock(); -}); +describe('AzureBlobStoragePublish', () => { + const entity = { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'backstage', + namespace: 'default', + annotations: {}, + }, + }; + + const entityName = { + kind: 'Component', + name: 'backstage', + namespace: 'default', + }; + + const techdocsMetadata = { + site_name: 'backstage', + site_description: 'site_content', + etag: 'etag', + }; + + const directory = getEntityRootDir(entity); + + beforeEach(() => { + (logger.error as jest.Mock).mockClear(); + }); + + const files = { + 'index.html': '', + '404.html': '', + 'techdocs_metadata.json': JSON.stringify(techdocsMetadata), + assets: { + 'main.css': '', + }, + html: { + 'unsafe.html': '', + }, + img: { + 'with spaces.png': 'found it', + 'unsafe.svg': '', + }, + 'some folder': { + 'also with spaces.js': 'found it too', + }, + }; + + beforeEach(async () => { + mockFs({ + [directory]: files, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); -describe('publishing with valid credentials', () => { describe('getReadiness', () => { it('should validate correct config', async () => { + const publisher = createPublisherFromConfig(); expect(await publisher.getReadiness()).toEqual({ isAvailable: true, }); }); it('should reject incorrect config', async () => { - const errorPublisher = createPublisherMock({ + const errorPublisher = createPublisherFromConfig({ containerName: 'bad_container', }); @@ -119,7 +143,7 @@ describe('publishing with valid credentials', () => { isAvailable: false, }); - expect(loggerErrorSpy).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( expect.stringContaining( `Could not retrieve metadata about the Azure Blob Storage container bad_container.`, ), @@ -128,35 +152,16 @@ describe('publishing with valid credentials', () => { }); describe('publish', () => { - beforeEach(() => { - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - - mockFs({ - [entityRootDir]: { - 'index.html': '', - '404.html': '', - assets: { - 'main.css': '', - }, - }, - }); - }); - - afterEach(() => { - mockFs.restore(); - }); - it('should publish a directory', async () => { - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); + const publisher = createPublisherFromConfig(); + expect(await publisher.publish({ entity, directory })).toBeUndefined(); + }); - expect( - await publisher.publish({ - entity, - directory: entityRootDir, - }), - ).toBeUndefined(); + it('should publish a directory as well when legacy casing is used', async () => { + const publisher = createPublisherFromConfig({ + legacyUseCaseSensitiveTripletPaths: true, + }); + expect(await publisher.publish({ entity, directory })).toBeUndefined(); }); it('should fail to publish a directory', async () => { @@ -168,7 +173,9 @@ describe('publishing with valid credentials', () => { 'generatedDirectory', ); - const entity = createMockEntity(); + const publisher = createPublisherFromConfig({ + containerName: 'bad_container', + }); const fails = publisher.publish({ entity, @@ -187,177 +194,131 @@ describe('publishing with valid credentials', () => { }); it('reports an error when bad account credentials', async () => { - publisher = createPublisherMock({ - accountName: 'failupload', - }); - - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - - mockFs({ - [entityRootDir]: { - 'index.html': '', - }, + const publisher = createPublisherFromConfig({ + accountName: 'bad_account_credentials', }); let error; try { - await publisher.publish({ - entity, - directory: entityRootDir, - }); + await publisher.publish({ entity, directory }); } catch (e) { error = e; } expect(error.message).toContain(`Unable to upload file(s) to Azure`); - expect(loggerErrorSpy).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( expect.stringContaining( `Unable to upload file(s) to Azure. Error: Upload failed for ${path.join( - entityRootDir, - 'index.html', + directory, + '404.html', )} with status code 500`, ), ); }); - - it('should delete stale files after upload', async () => { - const entity = createMockEntity(); - const directory = getEntityRootDir(entity); - const containerName = 'delete_stale_files_success'; - publisher = createPublisherMock({ containerName }); - await publisher.publish({ entity, directory }); - expect(loggerInfoSpy).toHaveBeenLastCalledWith( - `Successfully deleted stale files for Entity ${entity.metadata.name}. Total number of files: 1`, - ); - }); - - it('should log error when the stale files deletion fails', async () => { - const entity = createMockEntity(); - const directory = getEntityRootDir(entity); - const containerName = 'delete_stale_files_error'; - publisher = createPublisherMock({ containerName }); - await publisher.publish({ entity, directory }); - expect(loggerErrorSpy).toHaveBeenLastCalledWith( - 'Unable to delete file(s) from Azure. Error: Message', - ); - }); }); describe('hasDocsBeenGenerated', () => { - it('should return true if docs has been generated', async () => { - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - - mockFs({ - [entityRootDir]: { - 'index.html': 'file-content', - }, - }); - + it('should check expected file', async () => { + const publisher = createPublisherFromConfig(); + await publisher.publish({ entity, directory }); + expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true); + }); + + it('should check expected file when legacy case flag is passed', async () => { + const publisher = createPublisherFromConfig({ + legacyUseCaseSensitiveTripletPaths: true, + }); + await publisher.publish({ entity, directory }); expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true); - mockFs.restore(); }); it('should return false if docs has not been generated', async () => { - const entity = createMockEntity(); - - expect(await publisher.hasDocsBeenGenerated(entity)).toBe(false); + const publisher = createPublisherFromConfig(); + expect( + await publisher.hasDocsBeenGenerated({ + kind: 'triplet', + metadata: { + namespace: 'invalid', + name: 'path', + }, + } as Entity), + ).toBe(false); }); }); describe('fetchTechDocsMetadata', () => { it('should return tech docs metadata', async () => { - const entityNameMock = createMockEntityName(); - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); + const publisher = createPublisherFromConfig(); + await publisher.publish({ entity, directory }); + expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual( + techdocsMetadata, + ); + }); - mockFs({ - [entityRootDir]: { - 'techdocs_metadata.json': - '{"site_name": "backstage", "site_description": "site_content", "etag": "etag"}', - }, + it('should return tech docs metadata even if the legacy case is enabled', async () => { + const publisher = createPublisherFromConfig({ + legacyUseCaseSensitiveTripletPaths: true, }); - const expectedMetadata: TechDocsMetadata = { - site_name: 'backstage', - site_description: 'site_content', - etag: 'etag', - }; - expect( - await publisher.fetchTechDocsMetadata(entityNameMock), - ).toStrictEqual(expectedMetadata); - mockFs.restore(); + await publisher.publish({ entity, directory }); + expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual( + techdocsMetadata, + ); }); it('should return tech docs metadata when json encoded with single quotes', async () => { - const entityNameMock = createMockEntityName(); - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); + const techdocsMetadataPath = path.join( + directory, + 'techdocs_metadata.json', + ); + const techdocsMetadataContent = files['techdocs_metadata.json']; - mockFs({ - [entityRootDir]: { - 'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag'}`, - }, - }); + fs.writeFileSync( + techdocsMetadataPath, + techdocsMetadataContent.replace(/"/g, "'"), + ); - const expectedMetadata: TechDocsMetadata = { - site_name: 'backstage', - site_description: 'site_content', - etag: 'etag', - }; - expect( - await publisher.fetchTechDocsMetadata(entityNameMock), - ).toStrictEqual(expectedMetadata); - mockFs.restore(); + const publisher = createPublisherFromConfig(); + await publisher.publish({ entity, directory }); + + expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual( + techdocsMetadata, + ); + + fs.writeFileSync(techdocsMetadataPath, techdocsMetadataContent); }); it('should return an error if the techdocs_metadata.json file is not present', async () => { - const entityNameMock = createMockEntityName(); - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); + const publisher = createPublisherFromConfig(); - let error; - try { - await publisher.fetchTechDocsMetadata(entityNameMock); - } catch (e) { - error = e; - } + const invalidEntityName = { + namespace: 'invalid', + kind: 'triplet', + name: 'path', + }; - expect(error).toEqual( - new Error( - `TechDocs metadata fetch failed, The file ${path.join( - entityRootDir, - 'techdocs_metadata.json', - )} does not exist !`, - ), + const techDocsMetadaFilePath = path.join( + ...Object.values(invalidEntityName), + 'techdocs_metadata.json', ); + + const fails = publisher.fetchTechDocsMetadata(invalidEntityName); + + await expect(fails).rejects.toMatchObject({ + message: `TechDocs metadata fetch failed, The file ${techDocsMetadaFilePath} does not exist!`, + }); }); }); describe('docsRouter', () => { + const entityTripletPath = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + let app: express.Express; - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - beforeEach(() => { + beforeEach(async () => { + const publisher = createPublisherFromConfig(); + await publisher.publish({ entity, directory }); app = express().use(publisher.docsRouter()); - - mockFs.restore(); - mockFs({ - [entityRootDir]: { - html: { - 'unsafe.html': '', - }, - img: { - 'with spaces.png': 'found it', - 'unsafe.svg': '', - }, - 'some folder': { - 'also with spaces.js': 'found it too', - }, - }, - }); }); afterEach(() => { @@ -365,32 +326,41 @@ describe('publishing with valid credentials', () => { }); it('should pass expected object path to bucket', async () => { - const { - kind, - metadata: { namespace, name }, - } = entity; - // Ensures leading slash is trimmed and encoded path is decoded. const pngResponse = await request(app).get( - `/${namespace}/${kind}/${name}/img/with%20spaces.png`, + `/${entityTripletPath}/img/with%20spaces.png`, ); expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual( 'found it', ); const jsResponse = await request(app).get( - `/${namespace}/${kind}/${name}/some%20folder/also%20with%20spaces.js`, + `/${entityTripletPath}/some%20folder/also%20with%20spaces.js`, + ); + expect(jsResponse.text).toEqual('found it too'); + }); + + it('should pass expected object path to bucket even if the legacy case is enabled', async () => { + const publisher = createPublisherFromConfig({ + legacyUseCaseSensitiveTripletPaths: true, + }); + await publisher.publish({ entity, directory }); + app = express().use(publisher.docsRouter()); + // Ensures leading slash is trimmed and encoded path is decoded. + const pngResponse = await request(app).get( + `/${entityTripletPath}/img/with%20spaces.png`, + ); + expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual( + 'found it', + ); + const jsResponse = await request(app).get( + `/${entityTripletPath}/some%20folder/also%20with%20spaces.js`, ); expect(jsResponse.text).toEqual('found it too'); }); it('should pass text/plain content-type for html', async () => { - const { - kind, - metadata: { namespace, name }, - } = entity; - const htmlResponse = await request(app).get( - `/${namespace}/${kind}/${name}/html/unsafe.html`, + `/${entityTripletPath}/html/unsafe.html`, ); expect(htmlResponse.text).toEqual(''); expect(htmlResponse.header).toMatchObject({ @@ -398,7 +368,7 @@ describe('publishing with valid credentials', () => { }); const svgResponse = await request(app).get( - `/${namespace}/${kind}/${name}/img/unsafe.svg`, + `/${entityTripletPath}/img/unsafe.svg`, ); expect(svgResponse.text).toEqual(''); expect(svgResponse.header).toMatchObject({ diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts index f1c62f0819..4dfd8ba2f5 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -31,6 +31,7 @@ import { getCloudPathForLocalPath, getFileTreeRecursively, getHeadersForFileExtension, + lowerCaseEntityTriplet, getStaleFiles, lowerCaseEntityTripletInStoragePath, } from './helpers'; @@ -88,16 +89,28 @@ export class AzureBlobStoragePublish implements PublisherBase { credential, ); - return new AzureBlobStoragePublish(storageClient, containerName, logger); + const legacyPathCasing = + config.getOptionalBoolean( + 'techdocs.legacyUseCaseSensitiveTripletPaths', + ) || false; + + return new AzureBlobStoragePublish( + storageClient, + containerName, + legacyPathCasing, + logger, + ); } constructor( private readonly storageClient: BlobServiceClient, private readonly containerName: string, + private readonly legacyPathCasing: boolean, private readonly logger: Logger, ) { this.storageClient = storageClient; this.containerName = containerName; + this.legacyPathCasing = legacyPathCasing; this.logger = logger; } @@ -137,8 +150,14 @@ export class AzureBlobStoragePublish implements PublisherBase { * Directory structure used in the container is - entityNamespace/entityKind/entityName/index.html */ async publish({ entity, directory }: PublishRequest): Promise { + const useLegacyPathCasing = this.legacyPathCasing; + // First, try to retrieve a list of all individual files currently existing - const remoteFolder = getCloudPathForLocalPath(entity); + const remoteFolder = getCloudPathForLocalPath( + entity, + undefined, + useLegacyPathCasing, + ); let existingFiles: string[] = []; try { existingFiles = await this.getAllBlobsFromContainer({ @@ -169,7 +188,11 @@ export class AzureBlobStoragePublish implements PublisherBase { ); const response = await container .getBlockBlobClient( - getCloudPathForLocalPath(entity, relativeFilePath), + getCloudPathForLocalPath( + entity, + relativeFilePath, + useLegacyPathCasing, + ), ) .uploadFile(absoluteFilePath); @@ -212,6 +235,7 @@ export class AzureBlobStoragePublish implements PublisherBase { getCloudPathForLocalPath( entity, path.relative(directory, absoluteFilePath), + useLegacyPathCasing, ), ); @@ -263,7 +287,11 @@ export class AzureBlobStoragePublish implements PublisherBase { async fetchTechDocsMetadata( entityName: EntityName, ): Promise { - const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + const entityTriplet = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + const entityRootDir = this.legacyPathCasing + ? entityTriplet + : lowerCaseEntityTriplet(entityTriplet); + try { const techdocsMetadataJson = await this.download( this.containerName, @@ -289,8 +317,13 @@ export class AzureBlobStoragePublish implements PublisherBase { docsRouter(): express.Handler { return (req, res) => { // Decode and trim the leading forward slash + const decodedUri = decodeURI(req.path.replace(/^\//, '')); + // filePath example - /default/Component/documented-component/index.html - const filePath = decodeURI(req.path.replace(/^\//, '')); + const filePath = this.legacyPathCasing + ? decodedUri + : lowerCaseEntityTripletInStoragePath(decodedUri); + // Files with different extensions (CSS, HTML) need to be served with different headers const fileExtension = platformPath.extname(filePath); const responseHeaders = getHeadersForFileExtension(fileExtension); @@ -317,7 +350,11 @@ export class AzureBlobStoragePublish implements PublisherBase { * can be used to verify if there are any pre-generated docs available to serve. */ hasDocsBeenGenerated(entity: Entity): Promise { - const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + const entityTriplet = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + const entityRootDir = this.legacyPathCasing + ? entityTriplet + : lowerCaseEntityTriplet(entityTriplet); + return this.storageClient .getContainerClient(this.containerName) .getBlockBlobClient(`${entityRootDir}/index.html`) diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts index 65cbf3c5ac..aaa1f6deb5 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts @@ -15,43 +15,18 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { - Entity, - ENTITY_DEFAULT_NAMESPACE, - EntityName, -} from '@backstage/catalog-model'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import express from 'express'; import request from 'supertest'; import mockFs from 'mock-fs'; -import os from 'os'; import path from 'path'; +import fs from 'fs-extra'; import { GoogleGCSPublish } from './googleStorage'; -import { PublisherBase, TechDocsMetadata } from './types'; // NOTE: /packages/techdocs-common/__mocks__ is being used to mock Google Cloud Storage client library -const createMockEntity = (annotations = {}): Entity => { - return { - apiVersion: 'version', - kind: 'TestKind', - metadata: { - name: 'test-component-name', - namespace: 'test-namespace', - annotations: { - ...annotations, - }, - }, - }; -}; - -const createMockEntityName = (): EntityName => ({ - kind: 'TestKind', - name: 'test-component-name', - namespace: 'test-namespace', -}); - -const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; +const rootDir = global.rootDir; const getEntityRootDir = (entity: Entity) => { const { @@ -63,11 +38,17 @@ const getEntityRootDir = (entity: Entity) => { }; const logger = getVoidLogger(); -const loggerInfoSpy = jest.spyOn(logger, 'info').mockReturnValue(logger); -const loggerErrorSpy = jest.spyOn(logger, 'error').mockReturnValue(logger); +jest.spyOn(logger, 'info').mockReturnValue(logger); +jest.spyOn(logger, 'error').mockReturnValue(logger); -const createPublisherMock = (bucketName: string) => { - const mockConfig = new ConfigReader({ +const createPublisherFromConfig = ({ + bucketName = 'bucketName', + legacyUseCaseSensitiveTripletPaths = false, +}: { + bucketName?: string; + legacyUseCaseSensitiveTripletPaths?: boolean; +} = {}) => { + const config = new ConfigReader({ techdocs: { requestUrl: 'http://localhost:7000', publisher: { @@ -77,67 +58,95 @@ const createPublisherMock = (bucketName: string) => { bucketName, }, }, + legacyUseCaseSensitiveTripletPaths, }, }); - return GoogleGCSPublish.fromConfig(mockConfig, logger); + return GoogleGCSPublish.fromConfig(config, logger); }; -let publisher: PublisherBase; - -beforeEach(async () => { - loggerInfoSpy.mockClear(); - loggerErrorSpy.mockClear(); - mockFs.restore(); - publisher = createPublisherMock('bucketName'); -}); - describe('GoogleGCSPublish', () => { + const entity = { + apiVersion: 'version', + kind: 'Component', + metadata: { + name: 'backstage', + namespace: 'default', + annotations: {}, + }, + }; + + const entityName = { + kind: 'Component', + name: 'backstage', + namespace: 'default', + }; + + const techdocsMetadata = { + site_name: 'backstage', + site_description: 'site_content', + etag: 'etag', + }; + + const directory = getEntityRootDir(entity); + + const files = { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + 'techdocs_metadata.json': JSON.stringify(techdocsMetadata), + html: { + 'unsafe.html': '', + }, + img: { + 'with spaces.png': 'found it', + 'unsafe.svg': '', + }, + 'some folder': { + 'also with spaces.js': 'found it too', + }, + }; + + beforeAll(() => { + mockFs({ + [directory]: files, + }); + }); + + afterAll(() => { + mockFs.restore(); + }); + describe('getReadiness', () => { it('should validate correct config', async () => { + const publisher = createPublisherFromConfig(); expect(await publisher.getReadiness()).toEqual({ isAvailable: true, }); }); it('should reject incorrect config', async () => { - const errorPublisher = createPublisherMock('errorBucket'); - - expect(await errorPublisher.getReadiness()).toEqual({ + const publisher = createPublisherFromConfig({ + bucketName: 'bad_bucket_name', + }); + expect(await publisher.getReadiness()).toEqual({ isAvailable: false, }); }); }); describe('publish', () => { - beforeEach(() => { - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - - mockFs({ - [entityRootDir]: { - 'index.html': '', - '404.html': '', - assets: { - 'main.css': '', - }, - }, - }); - }); - - afterEach(() => { - mockFs.restore(); - }); - it('should publish a directory', async () => { - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); + const publisher = createPublisherFromConfig(); + expect(await publisher.publish({ entity, directory })).toBeUndefined(); + }); - expect( - await publisher.publish({ - entity, - directory: entityRootDir, - }), - ).toBeUndefined(); + it('should publish a directory as well when legacy casing is used', async () => { + const publisher = createPublisherFromConfig({ + legacyUseCaseSensitiveTripletPaths: true, + }); + expect(await publisher.publish({ entity, directory })).toBeUndefined(); }); it('should fail to publish a directory', async () => { @@ -149,14 +158,7 @@ describe('GoogleGCSPublish', () => { 'generatedDirectory', ); - const entity = createMockEntity(); - - await expect( - publisher.publish({ - entity, - directory: wrongPathToGeneratedDirectory, - }), - ).rejects.toThrowError(); + const publisher = createPublisherFromConfig(); const fails = publisher.publish({ entity, @@ -170,29 +172,26 @@ describe('GoogleGCSPublish', () => { `Unable to upload file(s) to Google Cloud Storage. Error: Failed to read template directory: ENOENT, no such file or directory`, ), }); + await expect(fails).rejects.toMatchObject({ message: expect.stringContaining(wrongPathToGeneratedDirectory), }); }); it('should delete stale files after upload', async () => { - const entity = createMockEntity(); - const directory = getEntityRootDir(entity); const bucketName = 'delete_stale_files_success'; - publisher = createPublisherMock(bucketName); + const publisher = createPublisherFromConfig({ bucketName }); await publisher.publish({ entity, directory }); - expect(loggerInfoSpy).toHaveBeenLastCalledWith( + expect(logger.info).toHaveBeenLastCalledWith( `Successfully deleted stale files for Entity ${entity.metadata.name}. Total number of files: 1`, ); }); it('should log error when the stale files deletion fails', async () => { - const entity = createMockEntity(); - const directory = getEntityRootDir(entity); const bucketName = 'delete_stale_files_error'; - publisher = createPublisherMock(bucketName); + const publisher = createPublisherFromConfig({ bucketName }); await publisher.publish({ entity, directory }); - expect(loggerErrorSpy).toHaveBeenLastCalledWith( + expect(logger.error).toHaveBeenLastCalledWith( 'Unable to delete file(s) from Google Cloud Storage. Error: Message', ); }); @@ -200,148 +199,144 @@ describe('GoogleGCSPublish', () => { describe('hasDocsBeenGenerated', () => { it('should return true if docs has been generated', async () => { - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - - mockFs({ - [entityRootDir]: { - 'index.html': 'file-content', - }, - }); - + const publisher = createPublisherFromConfig(); + await publisher.publish({ entity, directory }); + expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true); + }); + + it('should return true if docs has been generated even if the legacy case is enabled', async () => { + const publisher = createPublisherFromConfig({ + legacyUseCaseSensitiveTripletPaths: true, + }); + await publisher.publish({ entity, directory }); expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true); - mockFs.restore(); }); it('should return false if docs has not been generated', async () => { - const entity = createMockEntity(); - - expect(await publisher.hasDocsBeenGenerated(entity)).toBe(false); + const publisher = createPublisherFromConfig(); + expect( + await publisher.hasDocsBeenGenerated({ + kind: 'entity', + metadata: { + namespace: 'invalid', + name: 'triplet', + }, + } as Entity), + ).toBe(false); }); }); describe('fetchTechDocsMetadata', () => { - beforeEach(() => { - mockFs.restore(); + it('should return tech docs metadata', async () => { + const publisher = createPublisherFromConfig(); + await publisher.publish({ entity, directory }); + expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual( + techdocsMetadata, + ); }); - it('should return tech docs metadata', async () => { - const entityNameMock = createMockEntityName(); - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); - - mockFs({ - [entityRootDir]: { - 'techdocs_metadata.json': - '{"site_name": "backstage", "site_description": "site_content", "etag": "etag"}', - }, + it('should return tech docs metadata even if the legacy case is enabled', async () => { + const publisher = createPublisherFromConfig({ + legacyUseCaseSensitiveTripletPaths: true, }); - - const expectedMetadata: TechDocsMetadata = { - site_name: 'backstage', - site_description: 'site_content', - etag: 'etag', - }; - expect( - await publisher.fetchTechDocsMetadata(entityNameMock), - ).toStrictEqual(expectedMetadata); + await publisher.publish({ entity, directory }); + expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual( + techdocsMetadata, + ); }); it('should return tech docs metadata when json encoded with single quotes', async () => { - const entityNameMock = createMockEntityName(); - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); + const techdocsMetadataPath = path.join( + directory, + 'techdocs_metadata.json', + ); + const techdocsMetadataContent = files['techdocs_metadata.json']; - mockFs({ - [entityRootDir]: { - 'techdocs_metadata.json': - "{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag'}", - }, - }); + fs.writeFileSync( + techdocsMetadataPath, + techdocsMetadataContent.replace(/"/g, "'"), + ); - const expectedMetadata: TechDocsMetadata = { - site_name: 'backstage', - site_description: 'site_content', - etag: 'etag', - }; - expect( - await publisher.fetchTechDocsMetadata(entityNameMock), - ).toStrictEqual(expectedMetadata); - mockFs.restore(); + const publisher = createPublisherFromConfig(); + await publisher.publish({ entity, directory }); + + expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual( + techdocsMetadata, + ); + + fs.writeFileSync(techdocsMetadataPath, techdocsMetadataContent); }); it('should return an error if the techdocs_metadata.json file is not present', async () => { - const entityNameMock = createMockEntityName(); - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); + const publisher = createPublisherFromConfig(); - const fails = publisher.fetchTechDocsMetadata(entityNameMock); + const invalidEntityName = { + namespace: 'invalid', + kind: 'triplet', + name: 'path', + }; + + const techDocsMetadaFilePath = path.join( + ...Object.values(invalidEntityName), + 'techdocs_metadata.json', + ); + + const fails = publisher.fetchTechDocsMetadata(invalidEntityName); await expect(fails).rejects.toMatchObject({ - message: `The file ${path.join( - entityRootDir, - 'techdocs_metadata.json', - )} does not exist !`, + message: `The file ${techDocsMetadaFilePath} does not exist!`, }); }); }); describe('docsRouter', () => { - let app: express.Express; - const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); + const entityTripletPath = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; - beforeEach(() => { + let app: Express.Application; + + beforeEach(async () => { + const publisher = createPublisherFromConfig(); + await publisher.publish({ entity, directory }); app = express().use(publisher.docsRouter()); - - mockFs.restore(); - mockFs({ - [entityRootDir]: { - html: { - 'unsafe.html': '', - }, - img: { - 'with spaces.png': 'found it', - 'unsafe.svg': '', - }, - 'some folder': { - 'also with spaces.js': 'found it too', - }, - }, - }); - }); - - afterEach(() => { - mockFs.restore(); }); it('should pass expected object path to bucket', async () => { - const { - kind, - metadata: { namespace, name }, - } = entity; - // Ensures leading slash is trimmed and encoded path is decoded. const pngResponse = await request(app).get( - `/${namespace}/${kind}/${name}/img/with%20spaces.png`, + `/${entityTripletPath}/img/with%20spaces.png`, ); expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual( 'found it', ); const jsResponse = await request(app).get( - `/${namespace}/${kind}/${name}/some%20folder/also%20with%20spaces.js`, + `/${entityTripletPath}/some%20folder/also%20with%20spaces.js`, + ); + expect(jsResponse.text).toEqual('found it too'); + }); + + it('should pass expected object path to bucket even if the legacy case is enabled', async () => { + const publisher = createPublisherFromConfig({ + legacyUseCaseSensitiveTripletPaths: true, + }); + await publisher.publish({ entity, directory }); + app = express().use(publisher.docsRouter()); + + // Ensures leading slash is trimmed and encoded path is decoded. + const pngResponse = await request(app).get( + `/${entityTripletPath}/img/with%20spaces.png`, + ); + expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual( + 'found it', + ); + const jsResponse = await request(app).get( + `/${entityTripletPath}/some%20folder/also%20with%20spaces.js`, ); expect(jsResponse.text).toEqual('found it too'); }); it('should pass text/plain content-type for html', async () => { - const { - kind, - metadata: { namespace, name }, - } = entity; - const htmlResponse = await request(app).get( - `/${namespace}/${kind}/${name}/html/unsafe.html`, + `/${entityTripletPath}/html/unsafe.html`, ); expect(htmlResponse.text).toEqual(''); expect(htmlResponse.header).toMatchObject({ @@ -349,7 +344,7 @@ describe('GoogleGCSPublish', () => { }); const svgResponse = await request(app).get( - `/${namespace}/${kind}/${name}/img/unsafe.svg`, + `/${entityTripletPath}/img/unsafe.svg`, ); expect(svgResponse.text).toEqual(''); expect(svgResponse.header).toMatchObject({ diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts index 49156e07f2..1f519829f2 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -22,10 +22,12 @@ import path from 'path'; import { Readable } from 'stream'; import { Logger } from 'winston'; import { - bulkStorageOperation, - getCloudPathForLocalPath, getFileTreeRecursively, getHeadersForFileExtension, + lowerCaseEntityTriplet, + lowerCaseEntityTripletInStoragePath, + bulkStorageOperation, + getCloudPathForLocalPath, getStaleFiles, } from './helpers'; import { MigrateWriteStream } from './migrations'; @@ -70,16 +72,28 @@ export class GoogleGCSPublish implements PublisherBase { }), }); - return new GoogleGCSPublish(storageClient, bucketName, logger); + const legacyPathCasing = + config.getOptionalBoolean( + 'techdocs.legacyUseCaseSensitiveTripletPaths', + ) || false; + + return new GoogleGCSPublish( + storageClient, + bucketName, + legacyPathCasing, + logger, + ); } constructor( private readonly storageClient: Storage, private readonly bucketName: string, + private readonly legacyPathCasing: boolean, private readonly logger: Logger, ) { this.storageClient = storageClient; this.bucketName = bucketName; + this.legacyPathCasing = legacyPathCasing; this.logger = logger; } @@ -115,12 +129,17 @@ export class GoogleGCSPublish implements PublisherBase { * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html */ async publish({ entity, directory }: PublishRequest): Promise { + const useLegacyPathCasing = this.legacyPathCasing; const bucket = this.storageClient.bucket(this.bucketName); // First, try to retrieve a list of all individual files currently existing let existingFiles: string[] = []; try { - const remoteFolder = getCloudPathForLocalPath(entity); + const remoteFolder = getCloudPathForLocalPath( + entity, + undefined, + useLegacyPathCasing, + ); existingFiles = await this.getFilesForFolder(remoteFolder); } catch (e) { this.logger.error( @@ -140,7 +159,11 @@ export class GoogleGCSPublish implements PublisherBase { async absoluteFilePath => { const relativeFilePath = path.relative(directory, absoluteFilePath); return await bucket.upload(absoluteFilePath, { - destination: getCloudPathForLocalPath(entity, relativeFilePath), + destination: getCloudPathForLocalPath( + entity, + relativeFilePath, + useLegacyPathCasing, + ), }); }, absoluteFilesToUpload, @@ -163,6 +186,7 @@ export class GoogleGCSPublish implements PublisherBase { getCloudPathForLocalPath( entity, path.relative(directory, absoluteFilePath), + useLegacyPathCasing, ), ); const staleFiles = getStaleFiles(relativeFilesToUpload, existingFiles); @@ -186,7 +210,10 @@ export class GoogleGCSPublish implements PublisherBase { fetchTechDocsMetadata(entityName: EntityName): Promise { return new Promise((resolve, reject) => { - const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + const entityTriplet = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + const entityRootDir = this.legacyPathCasing + ? entityTriplet + : lowerCaseEntityTriplet(entityTriplet); const fileStreamChunks: Array = []; this.storageClient @@ -214,8 +241,12 @@ export class GoogleGCSPublish implements PublisherBase { docsRouter(): express.Handler { return (req, res) => { // Decode and trim the leading forward slash - // filePath example - /default/Component/documented-component/index.html - const filePath = decodeURI(req.path.replace(/^\//, '')); + const decodedUri = decodeURI(req.path.replace(/^\//, '')); + + // filePath example - /default/component/documented-component/index.html + const filePath = this.legacyPathCasing + ? decodedUri + : lowerCaseEntityTripletInStoragePath(decodedUri); // Files with different extensions (CSS, HTML) need to be served with different headers const fileExtension = path.extname(filePath); @@ -248,7 +279,11 @@ export class GoogleGCSPublish implements PublisherBase { */ async hasDocsBeenGenerated(entity: Entity): Promise { return new Promise(resolve => { - const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + const entityTriplet = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + const entityRootDir = this.legacyPathCasing + ? entityTriplet + : lowerCaseEntityTriplet(entityTriplet); + this.storageClient .bucket(this.bucketName) .file(`${entityRootDir}/index.html`) diff --git a/packages/techdocs-common/src/stages/publish/helpers.test.ts b/packages/techdocs-common/src/stages/publish/helpers.test.ts index b288d221d3..2babf547e4 100644 --- a/packages/techdocs-common/src/stages/publish/helpers.test.ts +++ b/packages/techdocs-common/src/stages/publish/helpers.test.ts @@ -131,23 +131,19 @@ describe('getStaleFiles', () => { describe('getCloudPathForLocalPath', () => { const entity: Entity = { apiVersion: 'version', - metadata: { namespace: 'default', name: 'backstage' }, + metadata: { namespace: 'custom', name: 'backstage' }, kind: 'Component', }; it('should compose a remote bucket path including entity information', () => { const remoteBucket = getCloudPathForLocalPath(entity); - expect(remoteBucket).toBe( - `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}/`, - ); + expect(remoteBucket).toBe('custom/component/backstage/'); }); it('should compose a remote filename including entity information', () => { const localPath = 'index.html'; const remoteBucket = getCloudPathForLocalPath(entity, localPath); - expect(remoteBucket).toBe( - `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}/${localPath}`, - ); + expect(remoteBucket).toBe(`custom/component/backstage/${localPath}`); }); it('should use the default namespace when it is undefined', () => { @@ -161,10 +157,15 @@ describe('getCloudPathForLocalPath', () => { localPath, ); expect(remoteBucket).toBe( - `${ENTITY_DEFAULT_NAMESPACE}/${entity.kind}/${entity.metadata.name}/${localPath}`, + `${ENTITY_DEFAULT_NAMESPACE}/component/backstage/${localPath}`, ); }); + it('should preserve case when legacy flag is passed', () => { + const remoteBucket = getCloudPathForLocalPath(entity, undefined, true); + expect(remoteBucket).toBe('custom/Component/backstage/'); + }); + it('should throw error when entity is invalid', () => { expect(() => getCloudPathForLocalPath({} as Entity)).toThrow(); }); diff --git a/packages/techdocs-common/src/stages/publish/helpers.ts b/packages/techdocs-common/src/stages/publish/helpers.ts index fda03e7415..0294ed9127 100644 --- a/packages/techdocs-common/src/stages/publish/helpers.ts +++ b/packages/techdocs-common/src/stages/publish/helpers.ts @@ -86,6 +86,23 @@ export const getFileTreeRecursively = async ( return fileList; }; +/** + * Returns a lower-cased version of entity's triplet in the form of a path. + * + * Path must not include a starting slash. + * + * @example + * lowerCaseEntityTriplet('default/Component/backstage') + * // return default/component/backstage + */ +export const lowerCaseEntityTriplet = (originalPath: string): string => { + const [namespace, kind, name, ...parts] = originalPath.split('/'); + const lowerNamespace = namespace.toLowerCase(); + const lowerKind = kind.toLowerCase(); + const lowerName = name.toLowerCase(); + return [lowerNamespace, lowerKind, lowerName, ...parts].join('/'); +}; + /** * Returns the version of an object's storage path where the first three parts * of the path (the entity triplet of namespace, kind, and name) are @@ -93,9 +110,11 @@ export const getFileTreeRecursively = async ( * * Path must not include a starting slash. * + * Throws an error if the path does not appear to be an entity triplet. + * * @example - * lowerCaseEntityTripletInStoragePath('default/Component/backstage') - * // return default/component/backstage + * lowerCaseEntityTripletInStoragePath('default/Component/backstage/file.txt') + * // return default/component/backstage/file.txt */ export const lowerCaseEntityTripletInStoragePath = ( originalPath: string, @@ -108,11 +127,7 @@ export const lowerCaseEntityTripletInStoragePath = ( `Encountered file unmanaged by TechDocs ${originalPath}. Skipping.`, ); } - const [namespace, kind, name, ...parts] = originalPath.split('/'); - const lowerNamespace = namespace.toLowerCase(); - const lowerKind = kind.toLowerCase(); - const lowerName = name.toLowerCase(); - return [lowerNamespace, lowerKind, lowerName, ...parts].join('/'); + return lowerCaseEntityTriplet(originalPath); }; // Only returns the files that existed previously and are not present anymore. @@ -131,6 +146,7 @@ export const getStaleFiles = ( export const getCloudPathForLocalPath = ( entity: Entity, localPath = '', + useLegacyPathCasing = false, ): string => { // Convert destination file path to a POSIX path for uploading. // GCS expects / as path separator and relativeFilePath will contain \\ on Windows. @@ -141,7 +157,13 @@ export const getCloudPathForLocalPath = ( const entityRootDir = `${ entity.metadata?.namespace ?? ENTITY_DEFAULT_NAMESPACE }/${entity.kind}/${entity.metadata.name}`; - return `${entityRootDir}/${relativeFilePathPosix}`; // GCS Bucket file relative path + + const relativeFilePathTriplet = `${entityRootDir}/${relativeFilePathPosix}`; + const destination = useLegacyPathCasing + ? relativeFilePathTriplet + : lowerCaseEntityTripletInStoragePath(relativeFilePathTriplet); + + return destination; // Remote storage file relative path }; // Perform rate limited generic operations by passing a function and a list of arguments diff --git a/packages/techdocs-common/src/types.d.ts b/packages/techdocs-common/src/types.d.ts new file mode 100644 index 0000000000..e085b95851 --- /dev/null +++ b/packages/techdocs-common/src/types.d.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +interface IStorageFilesMock { + emptyFiles(): void; + fileExists(targetPath: string): boolean; + readFile(targetPath: string): Buffer; + writeFile(targetPath: string, sourcePath: string): void; + writeFile(targetPath: string, sourceBuffer: Buffer): void; + writeFile(targetPath: string, source: string | Buffer): void; +} diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 63ff79ea0f..ec2aeafd00 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -128,6 +128,14 @@ const catalogPlugin: BackstagePlugin< }, { createComponent: ExternalRouteRef; + viewTechDoc: ExternalRouteRef< + { + name: string; + kind: string; + namespace: string; + }, + true + >; } >; export { catalogPlugin }; @@ -399,7 +407,7 @@ export const Router: ({ // src/components/EntityLayout/EntityLayout.d.ts:43:5 - (ae-forgotten-export) The symbol "EntityLayoutProps" needs to be exported by the entry point index.d.ts // src/components/EntityLayout/EntityLayout.d.ts:44:5 - (ae-forgotten-export) The symbol "SubRoute" needs to be exported by the entry point index.d.ts // src/components/EntityPageLayout/EntityPageLayout.d.ts:17:5 - (ae-forgotten-export) The symbol "EntityPageLayoutProps" needs to be exported by the entry point index.d.ts -// src/plugin.d.ts:17:5 - (ae-forgotten-export) The symbol "ColumnBreakpoints" needs to be exported by the entry point index.d.ts +// src/plugin.d.ts:22:5 - (ae-forgotten-export) The symbol "ColumnBreakpoints" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index 70b9781ec8..539cce7da3 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -29,6 +29,7 @@ import { ApiRegistry, ConfigReader, } from '@backstage/core-app-api'; +import { viewTechDocRouteRef } from '../../routes'; describe('', () => { it('renders info', async () => { @@ -203,4 +204,138 @@ describe('', () => { ); expect(getByText('View Source').closest('a')).not.toHaveAttribute('href'); }); + + it('renders techdocs link', async () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + annotations: { + 'backstage.io/techdocs-ref': './', + }, + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const apis = ApiRegistry.with( + scmIntegrationsApiRef, + ScmIntegrationsApi.fromConfig( + new ConfigReader({ + integrations: { + github: [ + { + host: 'github.com', + token: '...', + }, + ], + }, + }), + ), + ); + + const { getByText } = await renderInTestApp( + + + + + , + { + mountedRoutes: { + '/docs/:namespace/:kind/:name': viewTechDocRouteRef, + }, + }, + ); + + expect(getByText('View TechDocs').closest('a')).toHaveAttribute( + 'href', + '/docs/default/Component/software', + ); + }); + + it('renders disabled techdocs link when no docs exist', async () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const apis = ApiRegistry.with( + scmIntegrationsApiRef, + ScmIntegrationsApi.fromConfig( + new ConfigReader({ + integrations: { + github: [ + { + host: 'github.com', + token: '...', + }, + ], + }, + }), + ), + ); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('View TechDocs').closest('a')).not.toHaveAttribute('href'); + }); + + it('renders disbaled techdocs link when route is not bound', async () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + annotations: { + 'backstage.io/techdocs-ref': './', + }, + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const apis = ApiRegistry.with( + scmIntegrationsApiRef, + ScmIntegrationsApi.fromConfig( + new ConfigReader({ + integrations: { + github: [ + { + host: 'github.com', + token: '...', + }, + ], + }, + }), + ), + ); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('View TechDocs').closest('a')).not.toHaveAttribute('href'); + }); }); diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index d6227f9344..113ebe47bd 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -49,7 +49,8 @@ import { IconLinkVerticalProps, InfoCardVariants, } from '@backstage/core-components'; -import { useApi } from '@backstage/core-plugin-api'; +import { useApi, useRouteRef } from '@backstage/core-plugin-api'; +import { viewTechDocRouteRef } from '../../routes'; const useStyles = makeStyles({ gridItemCard: { @@ -81,6 +82,8 @@ export function AboutCard({ variant }: AboutCardProps) { const classes = useStyles(); const { entity } = useEntity(); const scmIntegrationsApi = useApi(scmIntegrationsApiRef); + const viewTechdocLink = useRouteRef(viewTechDocRouteRef); + const entitySourceLocation = getEntitySourceLocation( entity, scmIntegrationsApi, @@ -105,11 +108,17 @@ export function AboutCard({ variant }: AboutCardProps) { }; const viewInTechDocs: IconLinkVerticalProps = { label: 'View TechDocs', - disabled: !entity.metadata.annotations?.['backstage.io/techdocs-ref'], + disabled: + !entity.metadata.annotations?.['backstage.io/techdocs-ref'] || + !viewTechdocLink, icon: , - href: `/docs/${entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE}/${ - entity.kind - }/${entity.metadata.name}`, + href: + viewTechdocLink && + viewTechdocLink({ + namespace: entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE, + kind: entity.kind, + name: entity.metadata.name, + }), }; const viewApi: IconLinkVerticalProps = { title: hasApis ? '' : 'No APIs available', diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index c375e76239..c83716b32d 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -21,7 +21,7 @@ import { entityRouteRef, } from '@backstage/plugin-catalog-react'; import { CatalogClientWrapper } from './CatalogClientWrapper'; -import { createComponentRouteRef } from './routes'; +import { createComponentRouteRef, viewTechDocRouteRef } from './routes'; import { createApiFactory, createComponentExtension, @@ -50,6 +50,7 @@ export const catalogPlugin = createPlugin({ }, externalRoutes: { createComponent: createComponentRouteRef, + viewTechDoc: viewTechDocRouteRef, }, }); diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog/src/routes.ts index d1ee3e520a..63d9ccfb87 100644 --- a/plugins/catalog/src/routes.ts +++ b/plugins/catalog/src/routes.ts @@ -20,3 +20,9 @@ export const createComponentRouteRef = createExternalRouteRef({ id: 'create-component', optional: true, }); + +export const viewTechDocRouteRef = createExternalRouteRef({ + id: 'view-techdoc', + optional: true, + params: ['namespace', 'kind', 'name'], +}); diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index 7f2fdf8b26..2f1efa0037 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -246,5 +246,15 @@ export interface Config { * @deprecated */ storageUrl?: string; + + /** + * (Optional and not recommended) Prior to version [0.x.y] of TechDocs, docs + * sites could only be accessed over paths with case-sensitive entity triplets + * e.g. (namespace/Kind/name). If you are upgrading from an older version of + * TechDocs and are unable to perform the necessary migration of files in your + * external storage, you can set this value to `true` to temporarily revert to + * the old, case-sensitive entity triplet behavior. + */ + legacyUseCaseSensitiveTripletPaths?: boolean; }; } diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md index af12b42ad2..1533f9de49 100644 --- a/plugins/techdocs/api-report.md +++ b/plugins/techdocs/api-report.md @@ -271,6 +271,11 @@ export const TechDocsPicker: () => null; const techdocsPlugin: BackstagePlugin< { root: RouteRef; + docRoot: RouteRef<{ + name: string; + kind: string; + namespace: string; + }>; entityContent: RouteRef; }, {} @@ -374,7 +379,7 @@ export class TechDocsStorageClient implements TechDocsStorageApi { // // src/home/components/EntityListDocsTable.d.ts:11:5 - (ae-forgotten-export) The symbol "columnFactories" needs to be exported by the entry point index.d.ts // src/home/components/EntityListDocsTable.d.ts:12:5 - (ae-forgotten-export) The symbol "actionFactories" needs to be exported by the entry point index.d.ts -// src/plugin.d.ts:24:5 - (ae-forgotten-export) The symbol "TabsConfig" needs to be exported by the entry point index.d.ts +// src/plugin.d.ts:29:5 - (ae-forgotten-export) The symbol "TabsConfig" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts index ebc2aa2fd7..d6bf21b10b 100644 --- a/plugins/techdocs/config.d.ts +++ b/plugins/techdocs/config.d.ts @@ -26,6 +26,13 @@ export interface Config { */ builder: 'local' | 'external'; + /** + * Allows fallback to case-sensitive triplets in case of migration issues. + * @visibility frontend + * @see https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-alpha-to-beta + */ + legacyUseCaseSensitiveTripletPaths?: boolean; + /** * @example http://localhost:7000/api/techdocs * @visibility frontend diff --git a/plugins/techdocs/src/Router.tsx b/plugins/techdocs/src/Router.tsx index 3fb109e790..45b0dc756c 100644 --- a/plugins/techdocs/src/Router.tsx +++ b/plugins/techdocs/src/Router.tsx @@ -18,11 +18,6 @@ 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 { - rootRouteRef, - rootDocsRouteRef, - rootCatalogDocsRouteRef, -} from './routes'; import { TechDocsIndexPage } from './home/components/TechDocsIndexPage'; import { TechDocsPage as TechDocsReaderPage } from './reader/components/TechDocsPage'; import { EntityPageDocs } from './EntityPageDocs'; @@ -33,9 +28,9 @@ const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref'; export const Router = () => { return ( - } /> + } /> } /> @@ -58,10 +53,7 @@ export const EmbeddedDocsRouter = (_props: Props) => { return ( - } - /> + } /> ); }; diff --git a/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx b/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx index 0b1d6e8db2..48ea67585b 100644 --- a/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx +++ b/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx @@ -30,6 +30,7 @@ import { configApiRef, storageApiRef, } from '@backstage/core-plugin-api'; +import { rootDocsRouteRef } from '../../routes'; jest.mock('@backstage/plugin-catalog-react', () => { const actual = jest.requireActual('@backstage/plugin-catalog-react'); @@ -73,6 +74,11 @@ describe('TechDocs Home', () => { , + { + mountedRoutes: { + '/docs/:namespace/:kind/:name/*': rootDocsRouteRef, + }, + }, ); // Header diff --git a/plugins/techdocs/src/home/components/DocsCardGrid.test.tsx b/plugins/techdocs/src/home/components/DocsCardGrid.test.tsx index f9831e2fa4..8b0e31326c 100644 --- a/plugins/techdocs/src/home/components/DocsCardGrid.test.tsx +++ b/plugins/techdocs/src/home/components/DocsCardGrid.test.tsx @@ -17,11 +17,36 @@ import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import React from 'react'; +import { configApiRef } from '@backstage/core-plugin-api'; import { DocsCardGrid } from './DocsCardGrid'; +import { rootDocsRouteRef } from '../../routes'; + +// Hacky way to mock a specific boolean config value. +const getOptionalBooleanMock = jest.fn().mockReturnValue(false); +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApi: (apiRef: any) => { + const actualUseApi = jest.requireActual( + '@backstage/core-plugin-api', + ).useApi; + const actualApi = actualUseApi(apiRef); + if (apiRef === configApiRef) { + const configReader = actualApi; + configReader.getOptionalBoolean = getOptionalBooleanMock; + return configReader; + } + + return actualApi; + }, +})); describe('Entity Docs Card Grid', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('should render all entities passed ot it', async () => { - const { findByText } = render( + const { findByText, findAllByRole } = render( wrapInTestApp( { }, ]} />, + { + mountedRoutes: { + '/docs/:namespace/:kind/:name/*': rootDocsRouteRef, + }, + }, ), ); expect(await findByText('testName')).toBeInTheDocument(); expect(await findByText('testName2')).toBeInTheDocument(); + const [button1, button2] = await findAllByRole('button'); + expect(button1.getAttribute('href')).toContain( + '/docs/default/testkind/testname', + ); + expect(button2.getAttribute('href')).toContain( + '/docs/default/testkind2/testname2', + ); + }); + + it('should fall back to case-sensitive links when configured', async () => { + getOptionalBooleanMock.mockReturnValue(true); + + const { findByRole } = render( + wrapInTestApp( + , + { + mountedRoutes: { + '/techdocs/:namespace/:kind/:name/*': rootDocsRouteRef, + }, + }, + ), + ); + + const button = await findByRole('button'); + expect(getOptionalBooleanMock).toHaveBeenCalledWith( + 'techdocs.legacyUseCaseSensitiveTripletPaths', + ); + expect(button.getAttribute('href')).toContain( + '/techdocs/SomeNamespace/TestKind/testName', + ); }); }); diff --git a/plugins/techdocs/src/home/components/DocsCardGrid.tsx b/plugins/techdocs/src/home/components/DocsCardGrid.tsx index 500debaf2d..c8b856032f 100644 --- a/plugins/techdocs/src/home/components/DocsCardGrid.tsx +++ b/plugins/techdocs/src/home/components/DocsCardGrid.tsx @@ -15,9 +15,9 @@ */ import React from 'react'; -import { generatePath } from 'react-router-dom'; import { Entity } from '@backstage/catalog-model'; +import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; import { Card, CardActions, CardContent, CardMedia } from '@material-ui/core'; import { rootDocsRouteRef } from '../../routes'; @@ -32,6 +32,15 @@ export const DocsCardGrid = ({ }: { entities: Entity[] | undefined; }) => { + const getRouteToReaderPageFor = useRouteRef(rootDocsRouteRef); + + // Lower-case entity triplets by default, but allow override. + const toLowerMaybe = useApi(configApiRef).getOptionalBoolean( + 'techdocs.legacyUseCaseSensitiveTripletPaths', + ) + ? (str: string) => str + : (str: string) => str.toLocaleLowerCase(); + if (!entities) return null; return ( @@ -45,10 +54,12 @@ export const DocsCardGrid = ({ {entity.metadata.description}