From 2331b4e7d33fdd715430a22650c3fd5d3e7b9920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Birm=C3=A9?= Date: Sun, 8 Nov 2026 00:46:27 +0100 Subject: [PATCH] feat(core): require a password for new valkey-io-valkey instances createInstance refuses a valkey-io-valkey create without Password. The CLI create and web config commands, the VOD pipeline and ValkeyDb generate a password and store it as a secret. Part of Eyevinn/osaas-deploy-manager#2094 Co-Authored-By: Claude Sonnet 5.5 --- packages/cli/src/user/cmd.test.ts | 88 +++++++++++++++++++ packages/cli/src/user/cmd.ts | 12 +++ packages/cli/src/web/cmd.ts | 31 ++++++- packages/cli/src/web/config-create.test.ts | 57 ++++++++++++- packages/core/src/core.ts | 50 ++++++++++- packages/core/src/errors.ts | 10 +++ packages/core/src/index.ts | 3 + packages/core/src/valkey-password.test.ts | 98 ++++++++++++++++++++++ packages/db/src/index.ts | 3 + packages/db/src/valkey.test.ts | 83 ++++++++++++++++++ packages/db/src/valkey.ts | 18 +++- packages/transcode/src/vodpipeline.test.ts | 94 +++++++++++++++++++++ packages/transcode/src/vodpipeline.ts | 50 ++++++++++- 13 files changed, 588 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/user/cmd.test.ts create mode 100644 packages/core/src/valkey-password.test.ts create mode 100644 packages/db/src/valkey.test.ts create mode 100644 packages/transcode/src/vodpipeline.test.ts diff --git a/packages/cli/src/user/cmd.test.ts b/packages/cli/src/user/cmd.test.ts new file mode 100644 index 0000000..bece2b8 --- /dev/null +++ b/packages/cli/src/user/cmd.test.ts @@ -0,0 +1,88 @@ +import { Command } from 'commander'; +import { cmdCreate } from './cmd'; + +jest.mock('@osaas/client-core', () => ({ + Context: jest.fn().mockImplementation(() => ({ + getServiceAccessToken: jest.fn().mockResolvedValue('sat') + })), + clearSuspensionRecord: jest.fn().mockResolvedValue(undefined), + createInstance: jest.fn(), + createValkeyPasswordSecret: jest.fn().mockResolvedValue({ + password: 'topsecretvalue', + secretName: 'myvalkey', + secretRef: '{{secrets.myvalkey}}' + }) +})); + +import { createInstance, createValkeyPasswordSecret } from '@osaas/client-core'; + +const mockCreate = createInstance as jest.Mock; +const mockGenerate = createValkeyPasswordSecret as jest.Mock; + +describe('create command', () => { + let logSpy: jest.SpyInstance; + let errSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockCreate.mockResolvedValue({ name: 'myvalkey' }); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + errSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + logSpy.mockRestore(); + errSpy.mockRestore(); + }); + + async function run(...args: string[]) { + const cli = new Command(); + cli.option('--env ', 'Environment to use', 'prod'); + cli.addCommand(cmdCreate()); + await cli.parseAsync(['node', 'test', 'create', ...args]); + } + + test('generates a password secret for valkey-io-valkey without Password', async () => { + await run('valkey-io-valkey', 'myvalkey'); + expect(mockGenerate).toHaveBeenCalledWith(expect.anything(), 'myvalkey'); + expect(mockCreate).toHaveBeenCalledWith( + expect.anything(), + 'valkey-io-valkey', + 'sat', + { name: 'myvalkey', Password: '{{secrets.myvalkey}}' } + ); + const printed = [...logSpy.mock.calls, ...errSpy.mock.calls] + .flat() + .join('\n'); + expect(printed).toContain("secret 'myvalkey'"); + expect(printed).not.toContain('topsecretvalue'); + }); + + test('passes a supplied Password through', async () => { + await run('valkey-io-valkey', 'myvalkey', '-o', 'Password=mine'); + expect(mockGenerate).not.toHaveBeenCalled(); + expect(mockCreate).toHaveBeenCalledWith( + expect.anything(), + 'valkey-io-valkey', + 'sat', + { name: 'myvalkey', Password: 'mine' } + ); + }); + + test('passes a {{secrets.x}} reference through', async () => { + await run('valkey-io-valkey', 'myvalkey', '-o', 'Password={{secrets.x}}'); + expect(mockGenerate).not.toHaveBeenCalled(); + expect(mockCreate.mock.calls[0][3].Password).toBe('{{secrets.x}}'); + }); + + test('does not touch other service ids', async () => { + await run('eyevinn-test-adserver', 'myinstance'); + expect(mockGenerate).not.toHaveBeenCalled(); + expect(mockCreate).toHaveBeenCalledWith( + expect.anything(), + 'eyevinn-test-adserver', + 'sat', + { name: 'myinstance' } + ); + }); +}); diff --git a/packages/cli/src/user/cmd.ts b/packages/cli/src/user/cmd.ts index 6204fc7..df6fff4 100644 --- a/packages/cli/src/user/cmd.ts +++ b/packages/cli/src/user/cmd.ts @@ -2,6 +2,7 @@ import { clearSuspensionRecord, Context, createInstance, + createValkeyPasswordSecret, deleteSecret, getInstance, getLogsForInstance, @@ -80,6 +81,17 @@ export function cmdCreate() { const ctx = new Context({ environment }); const serviceAccessToken = await ctx.getServiceAccessToken(serviceId); + if (serviceId === 'valkey-io-valkey' && !payload['Password']) { + const generated = await createValkeyPasswordSecret( + ctx, + payload['name'] + ); + payload['Password'] = generated.secretRef; + console.error( + `Generated a password and stored it as secret '${generated.secretName}' for valkey-io-valkey` + ); + } + const instance = await createInstance( ctx, serviceId, diff --git a/packages/cli/src/web/cmd.ts b/packages/cli/src/web/cmd.ts index 7cef857..624c25f 100644 --- a/packages/cli/src/web/cmd.ts +++ b/packages/cli/src/web/cmd.ts @@ -1,10 +1,14 @@ import { + buildRedisUrl, clearSuspensionRecord, Context, createInstance, + createValkeyPasswordSecret, + GeneratedValkeyPassword, getInstance, getPortsForInstance, removeInstance, + saveSecret, waitForInstanceReady } from '@osaas/client-core'; import { @@ -105,6 +109,7 @@ export function cmdWeb() { const valkeyToken = await ctx.getServiceAccessToken( 'valkey-io-valkey' ); + let generated: GeneratedValkeyPassword | undefined; let valkeyInstance = await getInstance( ctx, 'valkey-io-valkey', @@ -112,12 +117,14 @@ export function cmdWeb() { valkeyToken ); if (!valkeyInstance) { + generated = await createValkeyPasswordSecret(ctx, name); valkeyInstance = await createInstance( ctx, 'valkey-io-valkey', valkeyToken, { - name + name, + Password: generated.secretRef } ); await clearSuspensionRecord(ctx, 'valkey-io-valkey', name); @@ -133,13 +140,33 @@ export function cmdWeb() { if (!redisPort) { throw new Error(`Failed to get redis port for instance ${name}`); } + let redisUrl = `redis://${redisPort.externalIp}:${redisPort.externalPort}`; + if (generated) { + // The orchestrator only resolves a secret reference that is the + // entire value, so the whole URL is stored as one secret. + const redisUrlSecretName = `redisurl${name}`; + await saveSecret( + 'eyevinn-app-config-svc', + redisUrlSecretName, + buildRedisUrl( + redisPort.externalIp, + redisPort.externalPort, + generated.password + ), + ctx + ); + redisUrl = `{{secrets.${redisUrlSecretName}}}`; + console.log( + `Stored the Valkey password as secret '${generated.secretName}' and the Redis URL as secret '${redisUrlSecretName}'` + ); + } configInstance = await createInstance( ctx, 'eyevinn-app-config-svc', configToken, { name, - RedisUrl: `redis://${redisPort.externalIp}:${redisPort.externalPort}` + RedisUrl: redisUrl } ); await clearSuspensionRecord(ctx, 'eyevinn-app-config-svc', name); diff --git a/packages/cli/src/web/config-create.test.ts b/packages/cli/src/web/config-create.test.ts index f7383aa..07f10d6 100644 --- a/packages/cli/src/web/config-create.test.ts +++ b/packages/cli/src/web/config-create.test.ts @@ -12,6 +12,16 @@ jest.mock('@osaas/client-core', () => ({ })), clearSuspensionRecord: jest.fn().mockResolvedValue(undefined), createInstance: jest.fn(), + createValkeyPasswordSecret: jest.fn().mockResolvedValue({ + password: 'pw+/=', + secretName: 'myconfig', + secretRef: '{{secrets.myconfig}}' + }), + buildRedisUrl: jest.fn( + (host: string, port: number, pw: string) => + `redis://:${encodeURIComponent(pw)}@${host}:${port}` + ), + saveSecret: jest.fn().mockResolvedValue(undefined), getInstance: jest.fn(), getPortsForInstance: jest.fn(), removeInstance: jest.fn(), @@ -22,9 +32,12 @@ import { clearSuspensionRecord, createInstance, getInstance, - getPortsForInstance + getPortsForInstance, + saveSecret } from '@osaas/client-core'; +const mockSaveSecret = saveSecret as jest.Mock; + const mockClear = clearSuspensionRecord as jest.Mock; const mockCreate = createInstance as jest.Mock; const mockGetInstance = getInstance as jest.Mock; @@ -81,6 +94,48 @@ describe('web config-create command', () => { ); }); + test('creates the Valkey with a generated password secret and wires the whole URL as a secret', async () => { + mockGetInstance.mockResolvedValue(undefined); + mockGetPorts.mockResolvedValue([ + { internalPort: 6379, externalIp: '1.2.3.4', externalPort: 1234 } + ]); + mockCreate.mockImplementation(async (_ctx, serviceId, _token, body) => ({ + name: body.name, + url: `https://${serviceId}.example` + })); + + await createTestCli().parseAsync([ + 'node', + 'test', + 'web', + 'config-create', + 'myconfig' + ]); + + expect(mockCreate).toHaveBeenCalledWith( + expect.anything(), + 'valkey-io-valkey', + 'sat', + { name: 'myconfig', Password: '{{secrets.myconfig}}' } + ); + expect(mockSaveSecret).toHaveBeenCalledWith( + 'eyevinn-app-config-svc', + 'redisurlmyconfig', + 'redis://:pw%2B%2F%3D@1.2.3.4:1234', + expect.anything() + ); + expect(mockCreate).toHaveBeenCalledWith( + expect.anything(), + 'eyevinn-app-config-svc', + 'sat', + { name: 'myconfig', RedisUrl: '{{secrets.redisurlmyconfig}}' } + ); + const printed = logSpy.mock.calls.flat().join('\n'); + expect(printed).toContain('redisurlmyconfig'); + expect(printed).not.toContain('pw+/='); + expect(printed).not.toContain('pw%2B'); + }); + test('does not clear when the instance already exists', async () => { mockGetInstance.mockResolvedValue({ name: 'myconfig', diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 399062b..6c9c63b 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -1,5 +1,6 @@ import { Context, Service } from './context'; -import { InvalidName, UnauthorizedError } from './errors'; +import { randomBytes } from 'crypto'; +import { InvalidName, PasswordRequired, UnauthorizedError } from './errors'; import { FetchError, createFetch } from './fetch'; import { Log } from './log'; @@ -62,6 +63,8 @@ export type Port = { internalPort: number; }; +const VALKEY_SERVICE_ID = 'valkey-io-valkey'; + /** * @typedef ServiceInstance * @type object @@ -95,6 +98,12 @@ export async function createInstance( if (!isValidInstanceName(body.name)) { throw new InvalidName(body.name); } + if ( + serviceId === VALKEY_SERVICE_ID && + (typeof body.Password !== 'string' || body.Password.trim() === '') + ) { + throw new PasswordRequired(serviceId); + } const service = await getService(context, serviceId); const instanceUrl = new URL(service.apiUrl); @@ -632,3 +641,42 @@ export async function deleteSecret( } }); } + +export type GeneratedValkeyPassword = { + /** The generated password. Never print or log this value. */ + password: string; + /** Name of the secret (service id valkey-io-valkey) that holds the password */ + secretName: string; + /** Value to pass as the Password parameter when creating the instance */ + secretRef: string; +}; + +/** + * Generate a random password for a new Valkey instance and store it as a + * secret for the valkey-io-valkey service. The secret is named after the + * instance. Pass `secretRef` as the `Password` parameter to `createInstance`. + * @memberof module:@osaas/client-core + * @param {Context} ctx - Open Source Cloud configuration context + * @param {string} name - Name of the Valkey instance + * @returns {GeneratedValkeyPassword} - The password, secret name and secret reference + */ +export async function createValkeyPasswordSecret( + ctx: Context, + name: string +): Promise { + const password = randomBytes(32).toString('base64'); + await saveSecret(VALKEY_SERVICE_ID, name, password, ctx); + return { password, secretName: name, secretRef: `{{secrets.${name}}}` }; +} + +/** + * Build a redis URL including the (URL-encoded) password + * @memberof module:@osaas/client-core + */ +export function buildRedisUrl( + host: string, + port: number | string, + password: string +): string { + return `redis://:${encodeURIComponent(password)}@${host}:${port}`; +} diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 32cc0e8..539e6cc 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -22,3 +22,13 @@ export class InstanceReadyTimeoutError extends Error { this.name = 'InstanceReadyTimeoutError'; } } + +export class PasswordRequired extends Error { + constructor(serviceId: string) { + super( + `A Password is required to create a ${serviceId} instance. ` + + `Supply a Password parameter or a {{secrets.}} reference.` + ); + this.name = 'PasswordRequired'; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bdfc68c..b132cbc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,9 @@ export { waitForInstanceReady, WaitForInstanceReadyOptions, saveSecret, + createValkeyPasswordSecret, + GeneratedValkeyPassword, + buildRedisUrl, listSecrets, getSecret, updateSecret, diff --git a/packages/core/src/valkey-password.test.ts b/packages/core/src/valkey-password.test.ts new file mode 100644 index 0000000..e97b0e8 --- /dev/null +++ b/packages/core/src/valkey-password.test.ts @@ -0,0 +1,98 @@ +import { Context } from './context'; +import { + buildRedisUrl, + createInstance, + createValkeyPasswordSecret +} from './core'; +import { PasswordRequired } from './errors'; +import { createFetch } from './fetch'; + +jest.mock('./fetch', () => ({ + ...jest.requireActual('./fetch'), + createFetch: jest.fn() +})); + +const mockCreateFetch = createFetch as jest.MockedFunction; + +describe('createInstance valkey-io-valkey password requirement', () => { + const ctx = new Context({ personalAccessToken: 'dummy' }); + + beforeEach(() => { + jest.clearAllMocks(); + mockCreateFetch.mockResolvedValueOnce([ + { serviceId: 'valkey-io-valkey', apiUrl: 'https://api.example.com' }, + { serviceId: 'eyevinn-test-adserver', apiUrl: 'https://api.example.com' } + ]); + mockCreateFetch.mockResolvedValueOnce({ name: 'myvalkey' }); + }); + + test.each([[undefined], [''], [' ']])( + 'refuses a create with Password %p before any request', + async (password) => { + await expect( + createInstance(ctx, 'valkey-io-valkey', 'token', { + name: 'myvalkey', + Password: password + }) + ).rejects.toThrow(PasswordRequired); + expect(mockCreateFetch).not.toHaveBeenCalled(); + } + ); + + test('allows a supplied Password', async () => { + await createInstance(ctx, 'valkey-io-valkey', 'token', { + name: 'myvalkey', + Password: 'secret' + }); + expect(mockCreateFetch).toHaveBeenCalledTimes(2); + }); + + test('allows a {{secrets.x}} reference', async () => { + await createInstance(ctx, 'valkey-io-valkey', 'token', { + name: 'myvalkey', + Password: '{{secrets.myvalkey}}' + }); + expect(mockCreateFetch).toHaveBeenCalledTimes(2); + }); + + test('does not affect other service ids', async () => { + await createInstance(ctx, 'eyevinn-test-adserver', 'token', { + name: 'myvalkey' + }); + expect(mockCreateFetch).toHaveBeenCalledTimes(2); + }); +}); + +describe('createValkeyPasswordSecret', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCreateFetch.mockResolvedValue(undefined); + }); + + test('stores a generated password as a secret and returns a reference', async () => { + const ctx = new Context({ personalAccessToken: 'dummy' }); + const result = await createValkeyPasswordSecret(ctx, 'myvalkey'); + expect(result.secretName).toBe('myvalkey'); + expect(result.secretRef).toBe('{{secrets.myvalkey}}'); + expect(Buffer.from(result.password, 'base64')).toHaveLength(32); + const [url, init] = mockCreateFetch.mock.calls[0]; + expect((url as URL).pathname).toBe('/mysecrets/valkey-io-valkey'); + expect(JSON.parse((init as any).body)).toEqual({ + secretName: 'myvalkey', + secretData: result.password + }); + }); + + test('generates a different password each time', async () => { + const ctx = new Context({ personalAccessToken: 'dummy' }); + const a = await createValkeyPasswordSecret(ctx, 'myvalkey'); + const b = await createValkeyPasswordSecret(ctx, 'myvalkey'); + expect(a.password).not.toEqual(b.password); + }); + + test('buildRedisUrl URL-encodes the password', () => { + expect(buildRedisUrl('1.2.3.4', 6379, 'a+b/c=')).toBe( + 'redis://:a%2Bb%2Fc%3D@1.2.3.4:6379' + ); + }); +}); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index ca79fa7..0b4e831 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -147,6 +147,9 @@ export async function setupDatabase( switch (type) { case 'valkey': { + if (!opts.password) { + throw new Error('Password is required for valkey'); + } const options: ValkeyIoValkeyConfig = { name, Password: opts.password, diff --git a/packages/db/src/valkey.test.ts b/packages/db/src/valkey.test.ts new file mode 100644 index 0000000..c18454a --- /dev/null +++ b/packages/db/src/valkey.test.ts @@ -0,0 +1,83 @@ +import { Context, getPortsForInstance } from '@osaas/client-core'; +import { + createValkeyIoValkeyInstance, + getValkeyIoValkeyInstance +} from '@osaas/client-services'; +import { setupDatabase } from '.'; +import { ValkeyDb } from './valkey'; + +jest.mock('@osaas/client-core', () => ({ + getInternalEndpoint: jest.fn(), + getPortsForInstance: jest.fn(), + Log: () => ({ error: jest.fn() }), + buildRedisUrl: jest.fn( + (host: string, port: number, pw: string) => + `redis://:${encodeURIComponent(pw)}@${host}:${port}` + ), + createValkeyPasswordSecret: jest.fn().mockResolvedValue({ + password: 'pw+/=', + secretName: 'myvalkey', + secretRef: '{{secrets.myvalkey}}' + }), + Context: jest.fn().mockImplementation(() => ({ + getServiceAccessToken: jest.fn().mockResolvedValue('token') + })) +})); + +jest.mock('@osaas/client-services', () => ({ + getValkeyIoValkeyInstance: jest.fn(), + createValkeyIoValkeyInstance: jest.fn().mockResolvedValue({}), + removeValkeyIoValkeyInstance: jest.fn() +})); + +const mockGetPorts = getPortsForInstance as jest.Mock; + +describe('valkey password', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetPorts.mockResolvedValue([ + { internalPort: 6379, externalIp: '1.2.3.4', externalPort: 1234 } + ]); + }); + + it('setupDatabase requires a password for valkey', async () => { + (getValkeyIoValkeyInstance as jest.Mock).mockResolvedValue(undefined); + await expect( + setupDatabase(new Context(), 'valkey', 'myvalkey', {}) + ).rejects.toThrow('Password is required for valkey'); + expect(createValkeyIoValkeyInstance).not.toHaveBeenCalled(); + }); + + it('setupDatabase passes a supplied password on create', async () => { + (getValkeyIoValkeyInstance as jest.Mock).mockResolvedValue(undefined); + await setupDatabase(new Context(), 'valkey', 'myvalkey', { + password: 'secret' + }).catch(() => undefined); + expect(createValkeyIoValkeyInstance).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ name: 'myvalkey', Password: 'secret' }) + ); + }); + + it('ValkeyDb generates a password secret on create and returns a URL with it', async () => { + (getValkeyIoValkeyInstance as jest.Mock).mockResolvedValue(undefined); + const db = new ValkeyDb({ context: new Context(), name: 'myvalkey' }); + const url = await db.getRedisUrl(); + expect(createValkeyIoValkeyInstance).toHaveBeenCalledWith( + expect.anything(), + { name: 'myvalkey', Password: '{{secrets.myvalkey}}' } + ); + expect(url?.password).toBe(encodeURIComponent('pw+/=')); + expect(url?.hostname).toBe('1.2.3.4'); + }); + + it('ValkeyDb does not generate for an existing instance', async () => { + (getValkeyIoValkeyInstance as jest.Mock).mockResolvedValue({ + name: 'myvalkey' + }); + const db = new ValkeyDb({ context: new Context(), name: 'myvalkey' }); + const url = await db.getRedisUrl(); + expect(createValkeyIoValkeyInstance).not.toHaveBeenCalled(); + expect(url?.password).toBe(''); + }); +}); diff --git a/packages/db/src/valkey.ts b/packages/db/src/valkey.ts index 3de3f29..4c9dc15 100644 --- a/packages/db/src/valkey.ts +++ b/packages/db/src/valkey.ts @@ -1,6 +1,9 @@ import { Context, Log, + buildRedisUrl, + GeneratedValkeyPassword, + createValkeyPasswordSecret, getPortsForInstance, listInstances } from '@osaas/client-core'; @@ -44,13 +47,24 @@ export class ValkeyDb { public async init() { const instance = await getValkeyIoValkeyInstance(this.context, this.name); + let generated: GeneratedValkeyPassword | undefined; if (!instance) { - await createValkeyIoValkeyInstance(this.context, { name: this.name }); + generated = await createValkeyPasswordSecret(this.context, this.name); + await createValkeyIoValkeyInstance(this.context, { + name: this.name, + Password: generated.secretRef + }); } const redisPort = await this.getRedisPort(); if (redisPort) { this.url = new URL( - `redis://${redisPort.externalIp}:${redisPort.externalPort}` + generated + ? buildRedisUrl( + redisPort.externalIp, + redisPort.externalPort, + generated.password + ) + : `redis://${redisPort.externalIp}:${redisPort.externalPort}` ); } } diff --git a/packages/transcode/src/vodpipeline.test.ts b/packages/transcode/src/vodpipeline.test.ts new file mode 100644 index 0000000..26507e4 --- /dev/null +++ b/packages/transcode/src/vodpipeline.test.ts @@ -0,0 +1,94 @@ +import { Context, getInstance, saveSecret } from '@osaas/client-core'; +import { + createEyevinnEncoreCallbackListenerInstance, + createEyevinnEncorePackagerInstance, + createValkeyIoValkeyInstance +} from '@osaas/client-services'; +import { createVodPipeline } from './vodpipeline'; + +jest.mock('./util', () => ({ delay: jest.fn().mockResolvedValue(undefined) })); + +jest.mock('@osaas/client-core', () => ({ + isValidInstanceName: () => true, + getInstance: jest.fn(), + getPortsForInstance: jest + .fn() + .mockResolvedValue([ + { internalPort: 6379, externalIp: '1.2.3.4', externalPort: 1234 } + ]), + removeInstance: jest.fn(), + saveSecret: jest.fn().mockResolvedValue(undefined), + waitForInstanceReady: jest.fn().mockResolvedValue(undefined), + buildRedisUrl: jest.fn( + (host: string, port: number, pw: string) => + `redis://:${encodeURIComponent(pw)}@${host}:${port}` + ), + createValkeyPasswordSecret: jest.fn().mockResolvedValue({ + password: 'pw+/=', + secretName: 'mypipe', + secretRef: '{{secrets.mypipe}}' + }) +})); + +jest.mock('@osaas/client-services', () => ({ + createEncoreInstance: jest + .fn() + .mockResolvedValue({ url: 'https://encore.example' }), + createEyevinnEncoreCallbackListenerInstance: jest + .fn() + .mockResolvedValue({ url: 'https://cb.example' }), + createEyevinnEncorePackagerInstance: jest + .fn() + .mockResolvedValue({ OutputFolder: 's3://out/' }), + createMinioMinioInstance: jest.fn(), + createValkeyIoValkeyInstance: jest.fn().mockResolvedValue({ name: 'mypipe' }) +})); + +jest.mock('minio', () => ({})); + +describe('createVodPipeline valkey password', () => { + const ctx = { + getServiceAccessToken: jest.fn().mockResolvedValue('sat'), + getPersonalAccessToken: jest.fn().mockReturnValue('pat') + } as unknown as Context; + + it('creates Valkey with a generated password secret and passes the URL as secrets', async () => { + (getInstance as jest.Mock).mockImplementation( + async (_c: unknown, serviceId: string) => + serviceId === 'encore' ? { url: 'https://encore.example' } : undefined + ); + + await createVodPipeline('mypipe', ctx, { + outputBucketName: 'out', + accessKeyId: 'ak', + secretAccessKey: 'sk', + endpoint: 'https://s3.example' + }); + + expect(createValkeyIoValkeyInstance).toHaveBeenCalledWith(ctx, { + name: 'mypipe', + Password: '{{secrets.mypipe}}' + }); + const fullUrl = 'redis://:pw%2B%2F%3D@1.2.3.4:1234'; + expect(saveSecret).toHaveBeenCalledWith( + 'eyevinn-encore-callback-listener', + 'redisurlmypipe', + fullUrl, + ctx + ); + expect(saveSecret).toHaveBeenCalledWith( + 'eyevinn-encore-packager', + 'redisurlmypipe', + fullUrl, + ctx + ); + expect(createEyevinnEncoreCallbackListenerInstance).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ RedisUrl: '{{secrets.redisurlmypipe}}' }) + ); + expect(createEyevinnEncorePackagerInstance).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ RedisUrl: '{{secrets.redisurlmypipe}}' }) + ); + }); +}); diff --git a/packages/transcode/src/vodpipeline.ts b/packages/transcode/src/vodpipeline.ts index 0215e07..299ae6c 100644 --- a/packages/transcode/src/vodpipeline.ts +++ b/packages/transcode/src/vodpipeline.ts @@ -1,5 +1,8 @@ import { Context, + buildRedisUrl, + createValkeyPasswordSecret, + GeneratedValkeyPassword, getInstance, getPortsForInstance, isValidInstanceName, @@ -127,8 +130,13 @@ async function createRedis(name: string, ctx: Context) { name, sat ); + let generated: GeneratedValkeyPassword | undefined; if (!instance) { - const config: ValkeyIoValkeyConfig = { name }; + generated = await createValkeyPasswordSecret(ctx, name); + const config: ValkeyIoValkeyConfig = { + name, + Password: generated.secretRef + }; const newInstance = await createValkeyIoValkeyInstance(ctx, config); await waitForInstanceReady('valkey-io-valkey', name, ctx); await delay(2000); @@ -141,9 +149,35 @@ async function createRedis(name: string, ctx: Context) { if (!redisPort) { throw new Error(`Failed to get redis port for instance ${name}`); } + if (generated) { + return buildRedisUrl( + redisPort.externalIp, + redisPort.externalPort, + generated.password + ); + } return `redis://${redisPort.externalIp}:${redisPort.externalPort}`; } +/** + * A Redis URL that carries a password must not be passed as a plain instance + * parameter. The orchestrator only resolves a secret reference that is the + * entire value, so the whole URL is stored as one secret per consuming service. + */ +async function redisUrlParam( + ctx: Context, + serviceId: string, + name: string, + redisUrl: string +) { + if (!redisUrl.includes('@')) { + return redisUrl; + } + const secretName = `redisurl${name}`; + await saveSecret(serviceId, secretName, redisUrl, ctx); + return `{{secrets.${secretName}}}`; +} + async function createCallbackListener( name: string, redisUrl: string, @@ -162,7 +196,12 @@ async function createCallbackListener( if (!instance) { const config: EyevinnEncoreCallbackListenerConfig = { name, - RedisUrl: redisUrl, + RedisUrl: await redisUrlParam( + ctx, + 'eyevinn-encore-callback-listener', + name, + redisUrl + ), RedisQueue: 'package', EncoreUrl: encoreUrl }; @@ -211,7 +250,12 @@ async function createPackager( ); const config: EyevinnEncorePackagerConfig = { name, - RedisUrl: redisUrl, + RedisUrl: await redisUrlParam( + ctx, + 'eyevinn-encore-packager', + name, + redisUrl + ), RedisQueue: 'package', OutputFolder: outputFolder, AwsAccessKeyId: accessKeyId,