feat: wire service registry targets

This commit is contained in:
2026-04-28 15:50:59 +00:00
parent 94f1199858
commit ee6d4c3d04
6 changed files with 267 additions and 22 deletions
+162 -7
View File
@@ -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<string, string>();
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<string, string>;
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']) {