From 46290006cb366ced8041fbc06621c809c85e689f Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Sat, 17 Oct 2026 07:06:26 +0000 Subject: [PATCH] feat(core): add clearSuspensionRecord and call it after CLI creates Co-Authored-By: Claude --- package-lock.json | 77 ++------------ packages/ai/package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/user/cmd.ts | 2 + packages/cli/src/web/cmd.ts | 3 + packages/cli/src/web/config-create.test.ts | 117 +++++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/suspension.test.ts | 60 +++++++++++ packages/core/src/suspension.ts | 48 +++++++++ packages/db/package.json | 2 +- packages/intercom/package.json | 2 +- packages/services/package.json | 2 +- packages/transcode/package.json | 2 +- packages/web/package.json | 2 +- 14 files changed, 245 insertions(+), 77 deletions(-) create mode 100644 packages/cli/src/web/config-create.test.ts create mode 100644 packages/core/src/suspension.test.ts create mode 100644 packages/core/src/suspension.ts diff --git a/package-lock.json b/package-lock.json index cc4014a..39e115f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14986,16 +14986,7 @@ "version": "0.5.5", "license": "MIT", "dependencies": { - "@osaas/client-core": "^0.26.0", - "chalk": "4.1.2" - } - }, - "packages/ai/node_modules/@osaas/client-core": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/@osaas/client-core/-/client-core-0.26.0.tgz", - "integrity": "sha512-WqALlfAwXb/mlMb5ZlyIkGx47MEgw3S37Hn2tCAPKKq7VOIQKbEX9vQLmabH4szac4rCe/f+IiUaNc5QMXOYPg==", - "license": "MIT", - "dependencies": { + "@osaas/client-core": "^0.28.0", "chalk": "4.1.2" } }, @@ -15005,7 +14996,7 @@ "license": "MIT", "dependencies": { "@osaas/client-ai": "^0.5.5", - "@osaas/client-core": "^0.26.0", + "@osaas/client-core": "^0.28.0", "@osaas/client-db": "^0.7.6", "@osaas/client-intercom": "^0.4.5", "@osaas/client-transcode": "^0.29.4", @@ -15021,15 +15012,6 @@ "osc": "dist/cli.js" } }, - "packages/cli/node_modules/@osaas/client-core": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/@osaas/client-core/-/client-core-0.26.0.tgz", - "integrity": "sha512-WqALlfAwXb/mlMb5ZlyIkGx47MEgw3S37Hn2tCAPKKq7VOIQKbEX9vQLmabH4szac4rCe/f+IiUaNc5QMXOYPg==", - "license": "MIT", - "dependencies": { - "chalk": "4.1.2" - } - }, "packages/core": { "name": "@osaas/client-core", "version": "0.28.0", @@ -15043,36 +15025,18 @@ "version": "0.7.6", "license": "MIT", "dependencies": { - "@osaas/client-core": "^0.26.0", + "@osaas/client-core": "^0.28.0", "@osaas/client-services": "^0.17.3", "chalk": "4.1.2", "ioredis": "^5.5.0" } }, - "packages/db/node_modules/@osaas/client-core": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/@osaas/client-core/-/client-core-0.26.0.tgz", - "integrity": "sha512-WqALlfAwXb/mlMb5ZlyIkGx47MEgw3S37Hn2tCAPKKq7VOIQKbEX9vQLmabH4szac4rCe/f+IiUaNc5QMXOYPg==", - "license": "MIT", - "dependencies": { - "chalk": "4.1.2" - } - }, "packages/intercom": { "name": "@osaas/client-intercom", "version": "0.4.5", "license": "MIT", "dependencies": { - "@osaas/client-core": "^0.26.0", - "chalk": "4.1.2" - } - }, - "packages/intercom/node_modules/@osaas/client-core": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/@osaas/client-core/-/client-core-0.26.0.tgz", - "integrity": "sha512-WqALlfAwXb/mlMb5ZlyIkGx47MEgw3S37Hn2tCAPKKq7VOIQKbEX9vQLmabH4szac4rCe/f+IiUaNc5QMXOYPg==", - "license": "MIT", - "dependencies": { + "@osaas/client-core": "^0.28.0", "chalk": "4.1.2" } }, @@ -15102,65 +15066,38 @@ "version": "0.17.3", "license": "MIT", "dependencies": { - "@osaas/client-core": "^0.26.0", + "@osaas/client-core": "^0.28.0", "chalk": "4.1.2" }, "devDependencies": { "openapi-typescript": "5.4.1" } }, - "packages/services/node_modules/@osaas/client-core": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/@osaas/client-core/-/client-core-0.26.0.tgz", - "integrity": "sha512-WqALlfAwXb/mlMb5ZlyIkGx47MEgw3S37Hn2tCAPKKq7VOIQKbEX9vQLmabH4szac4rCe/f+IiUaNc5QMXOYPg==", - "license": "MIT", - "dependencies": { - "chalk": "4.1.2" - } - }, "packages/transcode": { "name": "@osaas/client-transcode", "version": "0.29.4", "license": "MIT", "dependencies": { - "@osaas/client-core": "^0.26.0", + "@osaas/client-core": "^0.28.0", "@osaas/client-db": "^0.7.6", "@osaas/client-services": "^0.17.3", "chalk": "4.1.2", "minio": "^8.0.2" } }, - "packages/transcode/node_modules/@osaas/client-core": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/@osaas/client-core/-/client-core-0.26.0.tgz", - "integrity": "sha512-WqALlfAwXb/mlMb5ZlyIkGx47MEgw3S37Hn2tCAPKKq7VOIQKbEX9vQLmabH4szac4rCe/f+IiUaNc5QMXOYPg==", - "license": "MIT", - "dependencies": { - "chalk": "4.1.2" - } - }, "packages/web": { "name": "@osaas/client-web", "version": "0.6.6", "license": "MIT", "dependencies": { "@aws-sdk/client-cloudfront": "^3.730.0", - "@osaas/client-core": "^0.26.0", + "@osaas/client-core": "^0.28.0", "@osaas/client-services": "^0.17.3", "chalk": "4.1.2", "form-data": "^4.0.0", "mime": "^3.0.0", "minio": "^8.0.2" } - }, - "packages/web/node_modules/@osaas/client-core": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/@osaas/client-core/-/client-core-0.26.0.tgz", - "integrity": "sha512-WqALlfAwXb/mlMb5ZlyIkGx47MEgw3S37Hn2tCAPKKq7VOIQKbEX9vQLmabH4szac4rCe/f+IiUaNc5QMXOYPg==", - "license": "MIT", - "dependencies": { - "chalk": "4.1.2" - } } } } diff --git a/packages/ai/package.json b/packages/ai/package.json index e27b3f9..540938b 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -25,7 +25,7 @@ "test": "jest --passWithNoTests" }, "dependencies": { - "@osaas/client-core": "^0.26.0", + "@osaas/client-core": "^0.28.0", "chalk": "4.1.2" }, "publishConfig": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 738ac98..c3a0b62 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@osaas/client-ai": "^0.5.5", - "@osaas/client-core": "^0.26.0", + "@osaas/client-core": "^0.28.0", "@osaas/client-db": "^0.7.6", "@osaas/client-intercom": "^0.4.5", "@osaas/client-transcode": "^0.29.4", diff --git a/packages/cli/src/user/cmd.ts b/packages/cli/src/user/cmd.ts index 6ee7241..6204fc7 100644 --- a/packages/cli/src/user/cmd.ts +++ b/packages/cli/src/user/cmd.ts @@ -1,4 +1,5 @@ import { + clearSuspensionRecord, Context, createInstance, deleteSecret, @@ -85,6 +86,7 @@ export function cmdCreate() { serviceAccessToken, payload ); + await clearSuspensionRecord(ctx, serviceId, payload['name']); const isJson = command.optsWithGlobals().json; if (isJson) { process.stdout.write(JSON.stringify(instance) + '\n'); diff --git a/packages/cli/src/web/cmd.ts b/packages/cli/src/web/cmd.ts index 7f8ec39..7cef857 100644 --- a/packages/cli/src/web/cmd.ts +++ b/packages/cli/src/web/cmd.ts @@ -1,4 +1,5 @@ import { + clearSuspensionRecord, Context, createInstance, getInstance, @@ -119,6 +120,7 @@ export function cmdWeb() { name } ); + await clearSuspensionRecord(ctx, 'valkey-io-valkey', name); await waitForInstanceReady('valkey-io-valkey', name, ctx); } const ports = await getPortsForInstance( @@ -140,6 +142,7 @@ export function cmdWeb() { RedisUrl: `redis://${redisPort.externalIp}:${redisPort.externalPort}` } ); + await clearSuspensionRecord(ctx, 'eyevinn-app-config-svc', name); await waitForInstanceReady('eyevinn-app-config-svc', name, ctx); } console.log( diff --git a/packages/cli/src/web/config-create.test.ts b/packages/cli/src/web/config-create.test.ts new file mode 100644 index 0000000..f7383aa --- /dev/null +++ b/packages/cli/src/web/config-create.test.ts @@ -0,0 +1,117 @@ +import { Command } from 'commander'; +import { cmdWeb } from './cmd'; + +jest.mock('@osaas/client-web', () => ({ + publishToMyPages: jest.fn(), + createCloudfrontDistribution: jest.fn() +})); + +jest.mock('@osaas/client-core', () => ({ + Context: jest.fn().mockImplementation(() => ({ + getServiceAccessToken: jest.fn().mockResolvedValue('sat') + })), + clearSuspensionRecord: jest.fn().mockResolvedValue(undefined), + createInstance: jest.fn(), + getInstance: jest.fn(), + getPortsForInstance: jest.fn(), + removeInstance: jest.fn(), + waitForInstanceReady: jest.fn() +})); + +import { + clearSuspensionRecord, + createInstance, + getInstance, + getPortsForInstance +} from '@osaas/client-core'; + +const mockClear = clearSuspensionRecord as jest.Mock; +const mockCreate = createInstance as jest.Mock; +const mockGetInstance = getInstance as jest.Mock; +const mockGetPorts = getPortsForInstance as jest.Mock; + +describe('web config-create command', () => { + let logSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + function createTestCli(): Command { + const cli = new Command(); + cli.option('--env ', 'Environment to use', 'prod'); + cli.addCommand(cmdWeb()); + return cli; + } + + test('clears suspension records after each successful create', 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).toHaveBeenCalledTimes(2); + expect(mockClear).toHaveBeenCalledTimes(2); + expect(mockClear).toHaveBeenCalledWith( + expect.anything(), + 'valkey-io-valkey', + 'myconfig' + ); + expect(mockClear).toHaveBeenCalledWith( + expect.anything(), + 'eyevinn-app-config-svc', + 'myconfig' + ); + }); + + test('does not clear when the instance already exists', async () => { + mockGetInstance.mockResolvedValue({ + name: 'myconfig', + url: 'https://existing.example' + }); + + await createTestCli().parseAsync([ + 'node', + 'test', + 'web', + 'config-create', + 'myconfig' + ]); + + expect(mockCreate).not.toHaveBeenCalled(); + expect(mockClear).not.toHaveBeenCalled(); + }); + + test('does not clear when createInstance fails', async () => { + mockGetInstance.mockResolvedValue(undefined); + mockCreate.mockRejectedValue(new Error('create failed')); + + await createTestCli().parseAsync([ + 'node', + 'test', + 'web', + 'config-create', + 'myconfig' + ]); + + expect(mockClear).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith('create failed'); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9ce934d..bdfc68c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,3 +46,4 @@ export { export * from './platform/maker'; export * from './errors'; export * from './myapp'; +export { clearSuspensionRecord } from './suspension'; diff --git a/packages/core/src/suspension.test.ts b/packages/core/src/suspension.test.ts new file mode 100644 index 0000000..4039d61 --- /dev/null +++ b/packages/core/src/suspension.test.ts @@ -0,0 +1,60 @@ +import { Context } from './context'; +import { createFetch, FetchError } from './fetch'; +import { clearSuspensionRecord } from './suspension'; + +jest.mock('./fetch', () => ({ + ...jest.requireActual('./fetch'), + createFetch: jest.fn() +})); + +describe('clearSuspensionRecord', () => { + const ctx = new Context({ + personalAccessToken: 'dummy', + environment: 'dev' + }); + const mockFetch = createFetch as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('sends DELETE with PAT and no Content-Type on success', async () => { + mockFetch.mockResolvedValueOnce({ message: 'deleted' }); + await expect( + clearSuspensionRecord(ctx, 'svc-id', 'myname') + ).resolves.toBeUndefined(); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url.toString()).toEqual( + 'https://deploy.svc.dev.osaas.io/mysuspended/svc-id/myname' + ); + expect(options.method).toEqual('DELETE'); + expect(options.headers['x-pat-jwt']).toEqual('Bearer dummy'); + expect(options.headers['Content-Type']).toBeUndefined(); + }); + + test('treats 404 as a no-op', async () => { + mockFetch.mockRejectedValueOnce( + new FetchError({ message: 'not found', httpCode: 404 }) + ); + await expect( + clearSuspensionRecord(ctx, 'svc-id', 'myname') + ).resolves.toBeUndefined(); + }); + + test('swallows a 500 error', async () => { + mockFetch.mockRejectedValueOnce( + new FetchError({ message: 'boom', httpCode: 500 }) + ); + await expect( + clearSuspensionRecord(ctx, 'svc-id', 'myname') + ).resolves.toBeUndefined(); + }); + + test('swallows a network error', async () => { + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')); + await expect( + clearSuspensionRecord(ctx, 'svc-id', 'myname') + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/core/src/suspension.ts b/packages/core/src/suspension.ts new file mode 100644 index 0000000..83e5e6a --- /dev/null +++ b/packages/core/src/suspension.ts @@ -0,0 +1,48 @@ +import { Context } from './context'; +import { createFetch, FetchError } from './fetch'; +import { Log } from './log'; + +/** + * Clear a stale suspension record for a service instance name in Open Source + * Cloud. A record left behind by an earlier, deleted instance would otherwise + * exempt a recreated instance with the same name from suspension enforcement. + * This is best effort: a missing record (404) is a no-op and any other + * failure is logged at debug level and swallowed. It never throws. + * @memberof module:@osaas/client-core + * @param {Context} context - Open Source Cloud configuration context + * @param {string} serviceId - The service identifier + * @param {string} name - The name of the service instance + * @example + * import { Context, clearSuspensionRecord } from '@osaas/client-core'; + * const ctx = new Context(); + * await clearSuspensionRecord(ctx, 'eyevinn-test-adserver', 'my-instance'); + */ +export async function clearSuspensionRecord( + context: Context, + serviceId: string, + name: string +): Promise { + try { + const url = new URL( + `/mysuspended/${encodeURIComponent(serviceId)}/${encodeURIComponent( + name + )}`, + `https://deploy.svc.${context.getEnvironment()}.osaas.io` + ); + await createFetch(url, { + method: 'DELETE', + headers: { + 'x-pat-jwt': `Bearer ${context.getPersonalAccessToken()}` + } + }); + } catch (err) { + if (err instanceof FetchError && err.httpCode === 404) { + return; + } + Log().debug( + `Failed to clear suspension record for ${serviceId}/${name}: ${ + (err as Error).message + }` + ); + } +} diff --git a/packages/db/package.json b/packages/db/package.json index 8301cf8..4f1c65c 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -25,7 +25,7 @@ "test": "jest --passWithNoTests" }, "dependencies": { - "@osaas/client-core": "^0.26.0", + "@osaas/client-core": "^0.28.0", "@osaas/client-services": "^0.17.3", "chalk": "4.1.2", "ioredis": "^5.5.0" diff --git a/packages/intercom/package.json b/packages/intercom/package.json index e0bd993..dc4b1d1 100644 --- a/packages/intercom/package.json +++ b/packages/intercom/package.json @@ -25,7 +25,7 @@ "test": "jest --passWithNoTests" }, "dependencies": { - "@osaas/client-core": "^0.26.0", + "@osaas/client-core": "^0.28.0", "chalk": "4.1.2" }, "publishConfig": { diff --git a/packages/services/package.json b/packages/services/package.json index bfc4da4..27fc927 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -26,7 +26,7 @@ "test": "jest" }, "dependencies": { - "@osaas/client-core": "^0.26.0", + "@osaas/client-core": "^0.28.0", "chalk": "4.1.2" }, "publishConfig": { diff --git a/packages/transcode/package.json b/packages/transcode/package.json index 23296d0..0d35136 100644 --- a/packages/transcode/package.json +++ b/packages/transcode/package.json @@ -25,7 +25,7 @@ "test": "jest --passWithNoTests" }, "dependencies": { - "@osaas/client-core": "^0.26.0", + "@osaas/client-core": "^0.28.0", "@osaas/client-db": "^0.7.6", "@osaas/client-services": "^0.17.3", "chalk": "4.1.2", diff --git a/packages/web/package.json b/packages/web/package.json index 124a7ef..600ab80 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -26,7 +26,7 @@ }, "dependencies": { "@aws-sdk/client-cloudfront": "^3.730.0", - "@osaas/client-core": "^0.26.0", + "@osaas/client-core": "^0.28.0", "@osaas/client-services": "^0.17.3", "chalk": "4.1.2", "form-data": "^4.0.0",