414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
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<string, string>();
|
|
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<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,
|
|
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<TAuthenticatedRegistryUser | null> {
|
|
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));
|
|
}
|
|
}
|