diff --git a/app-config.yaml b/app-config.yaml index 3c4f2d7ea5..8224b2fb6a 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -26,6 +26,8 @@ backend: baseUrl: http://localhost:7000 listen: port: 7000 + cache: + store: memory database: client: sqlite3 connection: ':memory:' diff --git a/packages/backend-common/src/cache/CacheClient.ts b/packages/backend-common/src/cache/CacheClient.ts new file mode 100644 index 0000000000..9146131a10 --- /dev/null +++ b/packages/backend-common/src/cache/CacheClient.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { JsonValue } from '@backstage/config'; +import cacheManager from 'cache-manager'; +import { createHash } from 'crypto'; + +type CacheClientArgs = { + client: cacheManager.Cache; + pluginId: string; +}; + +export interface CacheClient { + get(key: string): Promise; + set(key: string, value: JsonValue, ttl?: number): Promise; + delete(key: string): Promise; +} + +export class ConcreteCacheClient implements CacheClient { + private readonly client: cacheManager.Cache; + private readonly pluginId: string; + + constructor({ client, pluginId }: CacheClientArgs) { + this.client = client; + this.pluginId = pluginId; + } + + async get(key: string): Promise { + const k = this.getNormalizedKey(key); + try { + const data = (await this.client.get(k)) as string | undefined; + return this.unserializeData(data); + } catch (_e) { + return null; + } + } + + async set(key: string, value: JsonValue, ttl?: number): Promise { + const k = this.getNormalizedKey(key); + try { + const data = this.serializeData(value); + await this.client.set(k, data, ttl ? { ttl } : undefined); + } catch (_e) { + return; + } + } + + async delete(key: string): Promise { + const k = this.getNormalizedKey(key); + try { + await this.client.del(k); + } catch (_e) { + return; + } + } + + /** + * Namespaces key by plugin to discourage cross-plugin integration via the + * cache store. + */ + private getNormalizedKey(key: string): string { + // Namespace key by plugin ID and remove potentially invalid characters. + const candidateKey = `${this.pluginId}:${key}`; + const wellFormedKey = Buffer.from(candidateKey).toString('base64'); + + // Memcache in particular doesn't do well with keys > 250 bytes. + if (wellFormedKey.length < 250) { + return wellFormedKey; + } + + return createHash('md5').update(candidateKey).digest('hex'); + } + + private serializeData(data: JsonValue): string { + return JSON.stringify(data); + } + + private unserializeData(data: string | undefined): JsonValue { + return data ? JSON.parse(data) : null; + } +} diff --git a/packages/backend-common/src/cache/CacheManager.ts b/packages/backend-common/src/cache/CacheManager.ts index 83e902a743..f1c3b279fd 100644 --- a/packages/backend-common/src/cache/CacheManager.ts +++ b/packages/backend-common/src/cache/CacheManager.ts @@ -14,20 +14,28 @@ * limitations under the License. */ -import { Config } from '@backstage/config'; +import { Config, ConfigReader } from '@backstage/config'; import cacheManager from 'cache-manager'; // @ts-expect-error import Memcache from 'memcache-pp'; // @ts-expect-error import memcachedStore from 'cache-manager-memcached-store'; +import { ConcreteCacheClient, CacheClient } from './CacheClient'; + +export type PluginCacheManager = { + getClient: (ttl: number) => CacheClient; +}; /** - * A Cache Manager, which is able to retrieve a `node-cache-manager`-compliant - * cache client, configured according to app configuration. If no cache is - * configured, or an unknown store is provided, a no-op cache instance will be - * returned. + * Implements a Cache Manager which will automatically create new cache clients + * for plugins when requested. All requested cacheclients are created with the + * credentials provided. */ export class CacheManager { + /** + * Keys represented supported `backend.cache.store` values, mapped to getters + * that return cacheManager.Cache instances appropriate to the store. + */ private readonly storeGetterMap = { memcache: this.getMemcacheClient, memory: this.getMemoryClient, @@ -41,12 +49,33 @@ export class CacheManager { * @param config The loaded application configuration. */ static fromConfig(config: Config): CacheManager { - return new CacheManager(config.getConfig('backend.cache')); + // If no `backend.cache` config is provided, instantiate the CacheManager + // with empty config; allowing a "none" cache client will be returned. + return new CacheManager( + config.getOptionalConfig('backend.cache') || new ConfigReader(undefined), + ); } private constructor(private readonly config: Config) {} - getClientWithTtl(ttl: number): cacheManager.Cache { + /** + * Generates a CacheManagerInstance for consumption by plugins. + * + * @param pluginId The plugin that the cache manager should be created for. Plugin names should be unique. + */ + forPlugin(pluginId: string): PluginCacheManager { + return { + getClient: (ttl: number): CacheClient => { + const concreteClient = this.getClientWithTtl(ttl); + return new ConcreteCacheClient({ + client: concreteClient, + pluginId: pluginId, + }); + }, + }; + } + + private getClientWithTtl(ttl: number): cacheManager.Cache { const store = this.config.getOptionalString( 'store', ) as keyof CacheManager['storeGetterMap']; diff --git a/packages/backend-common/src/cache/index.ts b/packages/backend-common/src/cache/index.ts index d405f02f46..ba1625d043 100644 --- a/packages/backend-common/src/cache/index.ts +++ b/packages/backend-common/src/cache/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ +export * from './CacheClient'; export * from './CacheManager'; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 82b8e1cb7b..d43c8f0101 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -60,11 +60,12 @@ function makeCreateEnv(config: Config) { root.info(`Created UrlReader ${reader}`); const databaseManager = SingleConnectionDatabaseManager.fromConfig(config); - const cache = CacheManager.fromConfig(config); + const cacheManager = CacheManager.fromConfig(config); return (plugin: string): PluginEnvironment => { const logger = root.child({ type: 'plugin', plugin }); const database = databaseManager.forPlugin(plugin); + const cache = cacheManager.forPlugin(plugin); return { logger, cache, database, config, reader, discovery }; }; } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index a951562553..356dd08d5f 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -17,7 +17,7 @@ import { Logger } from 'winston'; import { Config } from '@backstage/config'; import { - CacheManager, + PluginCacheManager, PluginDatabaseManager, PluginEndpointDiscovery, UrlReader, @@ -25,7 +25,7 @@ import { export type PluginEnvironment = { logger: Logger; - cache: CacheManager; + cache: PluginCacheManager; database: PluginDatabaseManager; config: Config; reader: UrlReader;