From ee6d4c3d04d1a7d962c7b3dd31c8c90150e31e54 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 28 Apr 2026 15:50:59 +0000 Subject: [PATCH] feat: wire service registry targets --- package.json | 4 +- pnpm-lock.yaml | 33 ++-- test/test.apiclient.ts | 33 ++++ .../classes.registrymanager.ts | 169 +++++++++++++++++- ts/manager.service/classes.service.ts | 4 +- ts/manager.service/classes.servicemanager.ts | 46 +++++ 6 files changed, 267 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 21139ac..9aefb80 100644 --- a/package.json +++ b/package.json @@ -74,8 +74,8 @@ "@push.rocks/smartunique": "^3.0.9", "@push.rocks/taskbuffer": "^3.4.0", "@push.rocks/webjwt": "^1.0.9", - "@serve.zone/api": "^5.3.2", - "@serve.zone/interfaces": "^5.4.5", + "@serve.zone/api": "^5.3.4", + "@serve.zone/interfaces": "^5.4.6", "@tsclass/tsclass": "^9.2.0" }, "files": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73fcf70..bd5a9aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,11 +141,11 @@ importers: specifier: ^1.0.9 version: 1.0.9 '@serve.zone/api': - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.3.4 + version: 5.3.4 '@serve.zone/interfaces': - specifier: ^5.4.5 - version: 5.4.5 + specifier: ^5.4.6 + version: 5.4.6 '@tsclass/tsclass': specifier: ^9.2.0 version: 9.5.0 @@ -1922,11 +1922,11 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@serve.zone/api@5.3.2': - resolution: {integrity: sha512-ETQ4KSNfhDP7O1WxXXLcMn/A+jZtDfd7FjuQ0k3n8tnXG9hExh8ZmqvMwVj8eT2CnXO+xQVlbAgT0HLMLnxCfA==} + '@serve.zone/api@5.3.4': + resolution: {integrity: sha512-3CqyeZkZPCJ4775UoNPKfknhTlAk6zmU/MVVSu6DoIAWgUaOuAlLUHlV45xIGtHmKAppsiYUoyoEhBLTZf9iMw==} - '@serve.zone/interfaces@5.4.5': - resolution: {integrity: sha512-asqUUjem3MGfIbseovHR8SxE+6FvjeQEYtV+PxcyY8YRXJ/vE3hNCDs7ePXgBbh4JXa+vNMaXHsFfz5Vrk6Ggg==} + '@serve.zone/interfaces@5.4.6': + resolution: {integrity: sha512-o4k7Wr6t3NLiP6gfAZZz8Jx8RlQ4sZYHTbhr4WkXzGf78vczFRIuFLyY1Y+TTNzDLEIzLVIyMsuECMV1KTwB2Q==} '@shikijs/engine-oniguruma@3.12.2': resolution: {integrity: sha512-hozwnFHsLvujK4/CPVHNo3Bcg2EsnG8krI/ZQ2FlBlCRpPZW4XAEQmEwqegJsypsTAN9ehu2tEYe30lYKSZW/w==} @@ -6808,8 +6808,10 @@ snapshots: transitivePeerDependencies: - '@nuxt/kit' - '@swc/helpers' + - bufferutil - react - supports-color + - utf-8-validate - vue '@happy-dom/global-registrator@15.11.7': @@ -7561,6 +7563,7 @@ snapshots: - '@mongodb-js/zstd' - '@nuxt/kit' - aws-crt + - bufferutil - encoding - gcp-metadata - kerberos @@ -7569,6 +7572,7 @@ snapshots: - snappy - socks - supports-color + - utf-8-validate - vue '@push.rocks/smartai@0.5.11(typescript@5.9.2)(ws@8.18.3)(zod@3.25.76)': @@ -8625,7 +8629,7 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@serve.zone/api@5.3.2': + '@serve.zone/api@5.3.4': dependencies: '@api.global/typedrequest': 3.1.10 '@api.global/typedrequest-interfaces': 3.0.19 @@ -8634,10 +8638,17 @@ snapshots: '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrx': 3.0.10 '@push.rocks/smartstream': 3.4.0 - '@serve.zone/interfaces': 5.4.5 + '@serve.zone/interfaces': 5.4.6 '@tsclass/tsclass': 9.5.0 + transitivePeerDependencies: + - '@nuxt/kit' + - bufferutil + - react + - supports-color + - utf-8-validate + - vue - '@serve.zone/interfaces@5.4.5': + '@serve.zone/interfaces@5.4.6': dependencies: '@api.global/typedrequest-interfaces': 3.0.19 '@push.rocks/smartlog-interfaces': 3.0.2 diff --git a/test/test.apiclient.ts b/test/test.apiclient.ts index 60b9549..4679b34 100644 --- a/test/test.apiclient.ts +++ b/test/test.apiclient.ts @@ -157,6 +157,39 @@ tap.test('should deny OCI registry push tokens for non-admin users', async () => expect(pushResponse.status).toEqual(403); }); +tap.test('should expose generated service registry targets', async () => { + const image = await testClient.image.createImage({ + name: 'Registry Target Test Image', + description: 'Image used by the registry target test', + }); + const service = await testClient.services.createService({ + name: 'Registry Target Test Service', + description: 'Service used by the registry target test', + imageId: image.id, + imageVersion: 'latest', + environment: {}, + secretBundleId: '', + serviceCategory: 'workload', + deploymentStrategy: 'custom', + scaleFactor: 1, + balancingStrategy: 'round-robin', + ports: { + web: 3000, + }, + domains: [], + deploymentIds: [], + }); + + const registryTarget = await testClient.services.getRegistryTarget(service.id, 'latest'); + expect(registryTarget.protocol).toEqual('oci'); + expect(registryTarget.registryHost).toEqual(`${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}`); + expect(registryTarget.repository.startsWith('workloads/registry-target-test-service-')).toBeTrue(); + expect(registryTarget.imageUrl).toEqual(`${registryTarget.registryHost}/${registryTarget.repository}:latest`); + + const refreshedService = await testClient.services.getServiceById(service.id); + expect(refreshedService.data.registryTarget?.imageUrl).toEqual(registryTarget.imageUrl); +}); + tap.test('should expose platform desired state', async () => { const capabilitiesResponse = await testClient.platform.getPlatformCapabilities(); expect(capabilitiesResponse.capabilities.find((capability) => capability.id === 'database')).toBeTruthy(); diff --git a/ts/manager.registry/classes.registrymanager.ts b/ts/manager.registry/classes.registrymanager.ts index dfac863..6406897 100644 --- a/ts/manager.registry/classes.registrymanager.ts +++ b/ts/manager.registry/classes.registrymanager.ts @@ -1,6 +1,7 @@ import type { Cloudly } from '../classes.cloudly.js'; import { logger } from '../logger.js'; import * as plugins from '../plugins.js'; +import type { Service } from '../manager.service/classes.service.js'; type TAuthenticatedRegistryUser = { userId: string; @@ -11,6 +12,7 @@ type TAuthenticatedRegistryUser = { export class CloudlyRegistryManager { private cloudlyRef: Cloudly; private smartRegistry!: plugins.smartregistry.SmartRegistry; + private recordedTagDigests = new Map(); private started = false; constructor(cloudlyRefArg: Cloudly) { @@ -27,6 +29,11 @@ export class CloudlyRegistryManager { this.smartRegistry = new plugins.smartregistry.SmartRegistry({ storage: s3Descriptor as plugins.smartregistry.IStorageConfig, + storageHooks: { + afterPut: async (contextArg) => { + await this.handleRegistryStorageAfterPut(contextArg); + }, + }, auth: { jwtSecret: registryJwtSecret, tokenStore: 'memory', @@ -93,6 +100,160 @@ export class CloudlyRegistryManager { } } + public getRegistryHost() { + if (!this.cloudlyRef.config.data.publicUrl) { + throw new Error('Cloudly registry requires publicUrl'); + } + + const publicPort = this.cloudlyRef.config.data.publicPort; + const includePort = + this.cloudlyRef.config.data.sslMode === 'none' && publicPort && !['80', '443'].includes(publicPort); + return `${this.cloudlyRef.config.data.publicUrl}${includePort ? `:${publicPort}` : ''}`; + } + + public getServiceRegistryTarget( + serviceArg: Service, + tagArg = 'latest', + ): plugins.servezoneInterfaces.data.IRegistryTarget { + const registryHost = this.getRegistryHost(); + const repository = this.getServiceRepository(serviceArg); + return { + protocol: 'oci', + registryHost, + repository, + tag: tagArg, + imageUrl: `${registryHost}/${repository}:${tagArg}`, + serviceId: serviceArg.id, + imageId: serviceArg.data?.imageId, + }; + } + + private async handleRegistryStorageAfterPut( + contextArg: plugins.smartregistry.IStorageHookContext, + ) { + try { + if (contextArg.protocol !== 'oci') { + return; + } + if (!contextArg.key.startsWith('oci/tags/') || !contextArg.key.endsWith('/tags.json')) { + return; + } + + const repository = contextArg.key.slice('oci/tags/'.length, -'/tags.json'.length); + const tagsBuffer = await this.smartRegistry.getStorage().getObject(contextArg.key); + if (!tagsBuffer) { + return; + } + + const tags = JSON.parse(tagsBuffer.toString('utf8')) as Record; + for (const [tag, digest] of Object.entries(tags)) { + const tagKey = `${repository}:${tag}`; + if (this.recordedTagDigests.get(tagKey) === digest) { + continue; + } + this.recordedTagDigests.set(tagKey, digest); + await this.recordRegistryPushEvent(repository, tag, digest, contextArg.actor?.userId); + } + } catch (error) { + logger.log('error', `registry push event handling failed: ${(error as Error).message}`); + } + } + + private async recordRegistryPushEvent( + repositoryArg: string, + tagArg: string, + digestArg: string, + actorUserIdArg?: string, + ) { + const service = await this.getServiceByRegistryRepository(repositoryArg); + if (!service) { + logger.log('info', `registry push for unmapped repository ${repositoryArg}:${tagArg}`); + return; + } + + const registryTarget = this.getServiceRegistryTarget(service, tagArg); + const pushEvent: plugins.servezoneInterfaces.data.IRegistryPushEvent = { + protocol: 'oci', + registryHost: registryTarget.registryHost, + repository: repositoryArg, + tag: tagArg, + digest: digestArg, + imageUrl: registryTarget.imageUrl, + pushedAt: Date.now(), + serviceId: service.id, + imageId: service.data.imageId, + actorUserId: actorUserIdArg, + }; + + service.data = { + ...service.data, + ...(service.data.deployOnPush === false ? {} : { imageVersion: tagArg }), + registryTarget, + }; + await service.save(); + + await this.recordImagePushEvent(service, pushEvent); + logger.log('info', `recorded registry push ${repositoryArg}:${tagArg} -> ${digestArg}`); + } + + private async recordImagePushEvent( + serviceArg: Service, + pushEventArg: plugins.servezoneInterfaces.data.IRegistryPushEvent, + ) { + if (!serviceArg.data.imageId) { + return; + } + + const image = await this.cloudlyRef.imageManager.CImage.getInstance({ + id: serviceArg.data.imageId, + }).catch(() => null); + if (!image) { + return; + } + + image.data.versions = image.data.versions || []; + const existingVersion = image.data.versions.find((versionArg) => { + return versionArg.versionString === pushEventArg.tag; + }); + const versionData = { + versionString: pushEventArg.tag, + digest: pushEventArg.digest, + registryRepository: pushEventArg.repository, + registryTag: pushEventArg.tag, + source: 'registry' as const, + size: existingVersion?.size || 0, + createdAt: existingVersion?.createdAt || pushEventArg.pushedAt, + }; + if (existingVersion) { + Object.assign(existingVersion, versionData); + } else { + image.data.versions.push(versionData); + } + image.data.lastPushEvent = pushEventArg; + await image.save(); + } + + private async getServiceByRegistryRepository(repositoryArg: string) { + const services = await this.cloudlyRef.serviceManager.CService.getInstances({}); + return services.find((serviceArg) => { + return this.getServiceRepository(serviceArg) === repositoryArg; + }); + } + + private getServiceRepository(serviceArg: Service) { + const serviceName = this.slugify(serviceArg.data?.name || serviceArg.id); + const serviceId = this.slugify(serviceArg.id).slice(0, 12) || serviceArg.id; + return `workloads/${serviceName}-${serviceId}`; + } + + private slugify(valueArg: string) { + return valueArg + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + || 'service'; + } + private async handleTokenRequest( req: plugins.typedserver.Request, res: plugins.typedserver.Response, @@ -202,13 +363,7 @@ export class CloudlyRegistryManager { } private getPublicRegistryUrl() { - if (!this.cloudlyRef.config.data.publicUrl) { - throw new Error('Cloudly registry requires publicUrl'); - } - const publicPort = this.cloudlyRef.config.data.publicPort; - const includePort = - this.cloudlyRef.config.data.sslMode === 'none' && publicPort && !['80', '443'].includes(publicPort); - return `${this.cloudlyRef.config.data.sslMode === 'none' ? 'http' : 'https'}://${this.cloudlyRef.config.data.publicUrl}${includePort ? `:${publicPort}` : ''}`; + return `${this.cloudlyRef.config.data.sslMode === 'none' ? 'http' : 'https'}://${this.getRegistryHost()}`; } private headersToRecord(headersArg: plugins.typedserver.Request['headers']) { diff --git a/ts/manager.service/classes.service.ts b/ts/manager.service/classes.service.ts index 85a7945..5628c05 100644 --- a/ts/manager.service/classes.service.ts +++ b/ts/manager.service/classes.service.ts @@ -24,7 +24,7 @@ export class Service extends plugins.smartdata.SmartDataDbDoc< public static async createService(serviceDataArg: Partial) { const service = new Service(); service.id = await Service.getNewId(); - Object.assign(service, serviceDataArg); + service.data = serviceDataArg as plugins.servezoneInterfaces.data.IService['data']; await service.save(); // Create DNS entries if service has web port and domains configured @@ -36,7 +36,7 @@ export class Service extends plugins.smartdata.SmartDataDbDoc< } // INSTANCE - @plugins.smartdata.svDb() + @plugins.smartdata.unI() public id: string; @plugins.smartdata.svDb() diff --git a/ts/manager.service/classes.servicemanager.ts b/ts/manager.service/classes.servicemanager.ts index 3ab5197..a51f280 100644 --- a/ts/manager.service/classes.servicemanager.ts +++ b/ts/manager.service/classes.servicemanager.ts @@ -38,6 +38,23 @@ export class ServiceManager { ) ); + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getServiceById', + async (dataArg) => { + await plugins.smartguard.passGuardsOrReject(dataArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + const service = await Service.getInstance({ + id: dataArg.serviceId, + }); + return { + service: await service.createSavableObject(), + }; + } + ) + ); + this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getServiceSecretBundlesAsFlatObject', @@ -53,11 +70,36 @@ export class ServiceManager { ) ); + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getServiceRegistryTarget', + async (dataArg) => { + await plugins.smartguard.passGuardsOrReject(dataArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + const service = await Service.getInstance({ + id: dataArg.serviceId, + }); + return { + registryTarget: this.cloudlyRef.registryManager.getServiceRegistryTarget( + service, + dataArg.tag || service.data.imageVersion || 'latest', + ), + }; + } + ) + ); + this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'createService', async (dataArg) => { const service = await Service.createService(dataArg.serviceData); + service.data.registryTarget = this.cloudlyRef.registryManager.getServiceRegistryTarget( + service, + service.data.imageVersion || 'latest', + ); + await service.save(); return { service: await service.createSavableObject(), }; @@ -76,6 +118,10 @@ export class ServiceManager { ...service.data, ...dataArg.serviceData, }; + service.data.registryTarget = this.cloudlyRef.registryManager.getServiceRegistryTarget( + service, + service.data.imageVersion || 'latest', + ); await service.save(); return { service: await service.createSavableObject(),