Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions packages/cli/src/user/cmd.test.ts
Original file line number Diff line number Diff line change
@@ -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>', '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' }
);
});
});
12 changes: 12 additions & 0 deletions packages/cli/src/user/cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
clearSuspensionRecord,
Context,
createInstance,
createValkeyPasswordSecret,
deleteSecret,
getInstance,
getLogsForInstance,
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 29 additions & 2 deletions packages/cli/src/web/cmd.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import {
buildRedisUrl,
clearSuspensionRecord,
Context,
createInstance,
createValkeyPasswordSecret,
GeneratedValkeyPassword,
getInstance,
getPortsForInstance,
removeInstance,
saveSecret,
waitForInstanceReady
} from '@osaas/client-core';
import {
Expand Down Expand Up @@ -105,19 +109,22 @@ export function cmdWeb() {
const valkeyToken = await ctx.getServiceAccessToken(
'valkey-io-valkey'
);
let generated: GeneratedValkeyPassword | undefined;
let valkeyInstance = await getInstance(
ctx,
'valkey-io-valkey',
name,
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);
Expand All @@ -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);
Expand Down
57 changes: 56 additions & 1 deletion packages/cli/src/web/config-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
50 changes: 49 additions & 1 deletion packages/core/src/core.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -62,6 +63,8 @@ export type Port = {
internalPort: number;
};

const VALKEY_SERVICE_ID = 'valkey-io-valkey';

/**
* @typedef ServiceInstance
* @type object
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<GeneratedValkeyPassword> {
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}`;
}
10 changes: 10 additions & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>}} reference.`
);
this.name = 'PasswordRequired';
}
}
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export {
waitForInstanceReady,
WaitForInstanceReadyOptions,
saveSecret,
createValkeyPasswordSecret,
GeneratedValkeyPassword,
buildRedisUrl,
listSecrets,
getSecret,
updateSecret,
Expand Down
Loading
Loading