feat: wire service registry targets
This commit is contained in:
@@ -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']) {
|
||||
|
||||
@@ -24,7 +24,7 @@ export class Service extends plugins.smartdata.SmartDataDbDoc<
|
||||
public static async createService(serviceDataArg: Partial<plugins.servezoneInterfaces.data.IService['data']>) {
|
||||
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()
|
||||
|
||||
@@ -38,6 +38,23 @@ export class ServiceManager {
|
||||
)
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.service.IRequest_Any_Cloudly_GetServiceById>(
|
||||
'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<plugins.servezoneInterfaces.requests.service.IRequest_Any_Cloudly_GetServiceSecretBundlesAsFlatObject>(
|
||||
'getServiceSecretBundlesAsFlatObject',
|
||||
@@ -53,11 +70,36 @@ export class ServiceManager {
|
||||
)
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.service.IRequest_Any_Cloudly_GetServiceRegistryTarget>(
|
||||
'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<plugins.servezoneInterfaces.requests.service.IRequest_Any_Cloudly_CreateService>(
|
||||
'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(),
|
||||
|
||||
Reference in New Issue
Block a user