f40ef6b7c0
Align Cloudly with the current typedserver, smartconfig, smartstate, and Docker tooling releases so builds and Docker output stay compatible with the upgraded stack.
417 lines
13 KiB
TypeScript
417 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(
|
|
ctx: plugins.typedserver.IRequestContext,
|
|
): Promise<Response> {
|
|
try {
|
|
const requestUrl = ctx.url;
|
|
|
|
if (requestUrl.pathname === '/v2/token') {
|
|
return await this.handleTokenRequest(ctx, requestUrl);
|
|
}
|
|
|
|
if (!this.started) {
|
|
return new Response('registry is not ready', { status: 503 });
|
|
}
|
|
|
|
const rawBody = Buffer.from(await ctx.request.arrayBuffer());
|
|
const response = await this.smartRegistry.handleRequest({
|
|
method: ctx.method || 'GET',
|
|
path: requestUrl.pathname,
|
|
query: Object.fromEntries(requestUrl.searchParams),
|
|
headers: this.headersToRecord(ctx.headers),
|
|
rawBody: rawBody.length > 0 ? rawBody : undefined,
|
|
});
|
|
|
|
return this.createRegistryResponse(response);
|
|
} catch (error) {
|
|
logger.log('error', `registry request failed: ${(error as Error).message}`);
|
|
return new Response('registry request failed', { status: 500 });
|
|
}
|
|
}
|
|
|
|
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);
|
|
if (service.data.deployOnPush !== false) {
|
|
await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows();
|
|
}
|
|
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/${this.slugify(`${serviceName}-${serviceId}`)}`;
|
|
}
|
|
|
|
private slugify(valueArg: string) {
|
|
return valueArg
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
|| 'service';
|
|
}
|
|
|
|
private async handleTokenRequest(
|
|
ctx: plugins.typedserver.IRequestContext,
|
|
requestUrl: URL,
|
|
): Promise<Response> {
|
|
const user = await this.authenticateRequest(ctx);
|
|
if (!user) {
|
|
return new Response('authentication required', {
|
|
status: 401,
|
|
headers: {
|
|
'WWW-Authenticate': 'Basic realm="Cloudly Registry"',
|
|
},
|
|
});
|
|
}
|
|
|
|
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) {
|
|
return new Response('registry write access denied', { status: 403 });
|
|
}
|
|
|
|
const token = await this.smartRegistry.getAuthManager().createOciToken(
|
|
user.userId,
|
|
requestedScopes,
|
|
3600,
|
|
);
|
|
return new Response(
|
|
JSON.stringify({
|
|
token,
|
|
access_token: token,
|
|
expires_in: 3600,
|
|
issued_at: new Date().toISOString(),
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
private async authenticateRequest(
|
|
ctx: plugins.typedserver.IRequestContext,
|
|
): Promise<TAuthenticatedRegistryUser | null> {
|
|
const credentials = this.getBasicCredentials(ctx);
|
|
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(ctx: plugins.typedserver.IRequestContext) {
|
|
const authHeader = ctx.headers.get('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: Headers) {
|
|
const headers: Record<string, string> = {};
|
|
headersArg.forEach((value, key) => {
|
|
headers[key.toLowerCase()] = value;
|
|
});
|
|
return headers;
|
|
}
|
|
|
|
private createRegistryResponse(
|
|
responseArg: plugins.smartregistry.IResponse,
|
|
): Response {
|
|
const headers = new Headers();
|
|
for (const [key, value] of Object.entries(responseArg.headers)) {
|
|
headers.set(key, value);
|
|
}
|
|
|
|
if (!responseArg.body) {
|
|
return new Response(null, {
|
|
status: responseArg.status,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
if (responseArg.body instanceof ReadableStream) {
|
|
return new Response(responseArg.body, {
|
|
status: responseArg.status,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
if (Buffer.isBuffer(responseArg.body) || typeof responseArg.body === 'string') {
|
|
return new Response(responseArg.body as BodyInit, {
|
|
status: responseArg.status,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
if (!headers.has('Content-Type')) {
|
|
headers.set('Content-Type', 'application/json');
|
|
}
|
|
return new Response(JSON.stringify(responseArg.body), {
|
|
status: responseArg.status,
|
|
headers,
|
|
});
|
|
}
|
|
}
|