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; username: string; canWrite: boolean; }; export class CloudlyRegistryManager { private cloudlyRef: Cloudly; private smartRegistry!: plugins.smartregistry.SmartRegistry; private recordedTagDigests = new Map(); private started = false; constructor(cloudlyRefArg: Cloudly) { this.cloudlyRef = cloudlyRefArg; } public async start() { const publicRegistryUrl = this.getPublicRegistryUrl(); const registryJwtSecret = JSON.stringify(this.cloudlyRef.authManager.smartjwtInstance.getKeyPairAsJson()); const s3Descriptor = this.cloudlyRef.config.data.s3Descriptor; if (!s3Descriptor?.bucketName) { throw new Error('Cloudly registry requires an S3 bucketName'); } 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', npmTokens: { enabled: false }, ociTokens: { enabled: true, realm: `${publicRegistryUrl}/v2/token`, service: this.cloudlyRef.config.data.publicUrl || 'cloudly', }, pypiTokens: { enabled: false }, rubygemsTokens: { enabled: false }, }, oci: { enabled: true, basePath: '/v2', registryUrl: publicRegistryUrl, }, }); await this.smartRegistry.init(); this.started = true; logger.log('info', `Cloudly OCI registry available at ${publicRegistryUrl}/v2`); } public async stop() { if (this.smartRegistry) { this.smartRegistry.destroy(); } this.started = false; } public async handleHttpRequest( req: plugins.typedserver.Request, res: plugins.typedserver.Response, ) { try { const requestUrl = new URL((req as any).originalUrl || req.url || '/', 'http://localhost'); if (requestUrl.pathname === '/v2/token') { await this.handleTokenRequest(req, res, requestUrl); return; } if (!this.started) { res.status(503); res.end('registry is not ready'); return; } const rawBody = await this.getRawBody(req); const response = await this.smartRegistry.handleRequest({ method: req.method || 'GET', path: requestUrl.pathname, query: Object.fromEntries(requestUrl.searchParams), headers: this.headersToRecord(req.headers), rawBody: rawBody.length > 0 ? rawBody : undefined, }); await this.sendRegistryResponse(res, response); } catch (error) { logger.log('error', `registry request failed: ${(error as Error).message}`); res.status(500); res.end('registry request failed'); } } 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, requestUrl: URL, ) { const user = await this.authenticateRequest(req); if (!user) { res.status(401); res.setHeader('WWW-Authenticate', 'Basic realm="Cloudly Registry"'); res.end('authentication required'); return; } const requestedScopes = this.getRequestedOciScopes(requestUrl.searchParams); const requestedWriteAccess = requestedScopes.some((scopeArg) => { const action = scopeArg.split(':').at(-1); return action === 'push' || action === 'delete'; }); if (requestedWriteAccess && !user.canWrite) { res.status(403); res.end('registry write access denied'); return; } const token = await this.smartRegistry.getAuthManager().createOciToken( user.userId, requestedScopes, 3600, ); res.status(200); res.setHeader('Content-Type', 'application/json'); res.end( JSON.stringify({ token, access_token: token, expires_in: 3600, issued_at: new Date().toISOString(), }), ); } private async authenticateRequest( req: plugins.typedserver.Request, ): Promise { const credentials = this.getBasicCredentials(req); if (!credentials) { return null; } const users = await this.cloudlyRef.authManager.CUser.getInstances({}); for (const user of users) { if (user.data?.username !== credentials.username) { continue; } const passwordMatches = user.data.password === credentials.password; const matchingToken = user.data.tokens?.find((tokenArg) => { return tokenArg.token === credentials.password && tokenArg.expiresAt > Date.now(); }); if (!passwordMatches && !matchingToken) { continue; } const assignedRoles = matchingToken?.assignedRoles || []; return { userId: user.id, username: user.data.username, canWrite: user.data.role === 'admin' || assignedRoles.includes('admin'), }; } return null; } private getBasicCredentials(req: plugins.typedserver.Request) { const authHeader = req.headers.authorization; if (!authHeader?.startsWith('Basic ')) { return null; } const decoded = Buffer.from(authHeader.slice('Basic '.length), 'base64').toString('utf8'); const separatorIndex = decoded.indexOf(':'); if (separatorIndex <= 0) { return null; } return { username: decoded.slice(0, separatorIndex), password: decoded.slice(separatorIndex + 1), }; } private getRequestedOciScopes(searchParamsArg: URLSearchParams) { const scopes: string[] = []; for (const scope of searchParamsArg.getAll('scope')) { const [scopeType, scopeName, actionsString] = scope.split(':'); if (scopeType !== 'repository' || !scopeName || !actionsString) { continue; } for (const action of actionsString.split(',')) { if (action) { scopes.push(`oci:${scopeType}:${scopeName}:${action}`); } } } return scopes; } private getPublicRegistryUrl() { return `${this.cloudlyRef.config.data.sslMode === 'none' ? 'http' : 'https'}://${this.getRegistryHost()}`; } private headersToRecord(headersArg: plugins.typedserver.Request['headers']) { return Object.fromEntries( Object.entries(headersArg).map(([key, value]) => [ key.toLowerCase(), Array.isArray(value) ? value.join(', ') : value || '', ]), ); } private async getRawBody(req: plugins.typedserver.Request) { const chunks: Buffer[] = []; for await (const chunk of req as any) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } return Buffer.concat(chunks); } private async sendRegistryResponse( res: plugins.typedserver.Response, responseArg: plugins.smartregistry.IResponse, ) { res.status(responseArg.status); for (const [key, value] of Object.entries(responseArg.headers)) { res.setHeader(key, value); } if (!responseArg.body) { res.end(); return; } if (responseArg.body instanceof ReadableStream) { plugins.stream.Readable.fromWeb(responseArg.body as any).pipe(res); return; } if (Buffer.isBuffer(responseArg.body) || typeof responseArg.body === 'string') { res.end(responseArg.body); return; } res.setHeader('Content-Type', responseArg.headers['Content-Type'] || 'application/json'); res.end(JSON.stringify(responseArg.body)); } }