multi registry support v3
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { BaseRegistry } from '../core/classes.baseregistry.js';
|
||||
import { RegistryStorage } from '../core/classes.registrystorage.js';
|
||||
import { AuthManager } from '../core/classes.authmanager.js';
|
||||
import { IRequestContext, IResponse, IAuthToken, IRegistryError } from '../core/interfaces.core.js';
|
||||
import {
|
||||
import type { IRequestContext, IResponse, IAuthToken, IRegistryError } from '../core/interfaces.core.js';
|
||||
import type {
|
||||
IUploadSession,
|
||||
IOciManifest,
|
||||
IOciImageIndex,
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { IRegistryConfig, IOciManifest, IOciImageIndex, ITagList } from './interfaces.js';
|
||||
|
||||
/**
|
||||
* Storage layer for OCI registry using SmartBucket
|
||||
*/
|
||||
export class RegistryStorage {
|
||||
private smartBucket: plugins.smartbucket.SmartBucket;
|
||||
private bucket: plugins.smartbucket.Bucket;
|
||||
private bucketName: string;
|
||||
|
||||
constructor(private config: IRegistryConfig['storage']) {
|
||||
this.bucketName = config.bucketName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the storage backend
|
||||
*/
|
||||
public async init() {
|
||||
this.smartBucket = new plugins.smartbucket.SmartBucket({
|
||||
accessKey: this.config.accessKey,
|
||||
accessSecret: this.config.accessSecret,
|
||||
endpoint: this.config.endpoint,
|
||||
port: this.config.port || 443,
|
||||
useSsl: this.config.useSsl !== false,
|
||||
region: this.config.region || 'us-east-1',
|
||||
});
|
||||
|
||||
// Ensure bucket exists
|
||||
await this.smartBucket.createBucket(this.bucketName).catch(() => {
|
||||
// Bucket may already exist, that's fine
|
||||
});
|
||||
|
||||
this.bucket = await this.smartBucket.getBucketByName(this.bucketName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a blob
|
||||
* @param digest - Content digest (e.g., "sha256:abc123...")
|
||||
* @param data - Blob data
|
||||
*/
|
||||
public async putBlob(digest: string, data: Buffer): Promise<void> {
|
||||
const path = this.getBlobPath(digest);
|
||||
await this.bucket.fastPut({
|
||||
path,
|
||||
contents: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a blob
|
||||
* @param digest - Content digest
|
||||
* @returns Blob data or null if not found
|
||||
*/
|
||||
public async getBlob(digest: string): Promise<Buffer | null> {
|
||||
const path = this.getBlobPath(digest);
|
||||
try {
|
||||
return await this.bucket.fastGet({ path });
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if blob exists
|
||||
* @param digest - Content digest
|
||||
* @returns true if exists
|
||||
*/
|
||||
public async blobExists(digest: string): Promise<boolean> {
|
||||
const path = this.getBlobPath(digest);
|
||||
return await this.bucket.fastExists({ path });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a blob
|
||||
* @param digest - Content digest
|
||||
*/
|
||||
public async deleteBlob(digest: string): Promise<void> {
|
||||
const path = this.getBlobPath(digest);
|
||||
await this.bucket.fastRemove({ path });
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a manifest
|
||||
* @param repository - Repository name (e.g., "library/nginx")
|
||||
* @param reference - Tag or digest
|
||||
* @param manifest - Manifest content
|
||||
* @param contentType - Manifest media type
|
||||
*/
|
||||
public async putManifest(
|
||||
repository: string,
|
||||
reference: string,
|
||||
manifest: IOciManifest | IOciImageIndex,
|
||||
contentType: string
|
||||
): Promise<string> {
|
||||
const manifestJson = JSON.stringify(manifest);
|
||||
const manifestBuffer = Buffer.from(manifestJson, 'utf-8');
|
||||
|
||||
// Calculate digest
|
||||
const digest = await this.calculateDigest(manifestBuffer);
|
||||
|
||||
// Store by digest
|
||||
const digestPath = this.getManifestPath(repository, digest);
|
||||
await this.bucket.fastPut({
|
||||
path: digestPath,
|
||||
contents: manifestBuffer,
|
||||
meta: { 'Content-Type': contentType },
|
||||
});
|
||||
|
||||
// If reference is a tag (not a digest), create/update tag mapping
|
||||
if (!reference.startsWith('sha256:')) {
|
||||
await this.putTag(repository, reference, digest);
|
||||
}
|
||||
|
||||
return digest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a manifest
|
||||
* @param repository - Repository name
|
||||
* @param reference - Tag or digest
|
||||
* @returns Manifest data and content type, or null if not found
|
||||
*/
|
||||
public async getManifest(
|
||||
repository: string,
|
||||
reference: string
|
||||
): Promise<{ data: Buffer; contentType: string } | null> {
|
||||
let digest = reference;
|
||||
|
||||
// If reference is a tag, resolve to digest
|
||||
if (!reference.startsWith('sha256:')) {
|
||||
const resolvedDigest = await this.getTagDigest(repository, reference);
|
||||
if (!resolvedDigest) return null;
|
||||
digest = resolvedDigest;
|
||||
}
|
||||
|
||||
const path = this.getManifestPath(repository, digest);
|
||||
try {
|
||||
const data = await this.bucket.fastGet({ path });
|
||||
// TODO: Retrieve content type from metadata if SmartBucket supports it
|
||||
const contentType = 'application/vnd.oci.image.manifest.v1+json';
|
||||
return { data, contentType };
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if manifest exists
|
||||
* @param repository - Repository name
|
||||
* @param reference - Tag or digest
|
||||
* @returns true if exists
|
||||
*/
|
||||
public async manifestExists(repository: string, reference: string): Promise<boolean> {
|
||||
let digest = reference;
|
||||
|
||||
// If reference is a tag, resolve to digest
|
||||
if (!reference.startsWith('sha256:')) {
|
||||
const resolvedDigest = await this.getTagDigest(repository, reference);
|
||||
if (!resolvedDigest) return false;
|
||||
digest = resolvedDigest;
|
||||
}
|
||||
|
||||
const path = this.getManifestPath(repository, digest);
|
||||
return await this.bucket.fastExists({ path });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a manifest
|
||||
* @param repository - Repository name
|
||||
* @param digest - Manifest digest (must be digest, not tag)
|
||||
*/
|
||||
public async deleteManifest(repository: string, digest: string): Promise<void> {
|
||||
const path = this.getManifestPath(repository, digest);
|
||||
await this.bucket.fastRemove({ path });
|
||||
}
|
||||
|
||||
/**
|
||||
* Store tag mapping
|
||||
* @param repository - Repository name
|
||||
* @param tag - Tag name
|
||||
* @param digest - Manifest digest
|
||||
*/
|
||||
public async putTag(repository: string, tag: string, digest: string): Promise<void> {
|
||||
const tags = await this.getTags(repository);
|
||||
tags[tag] = digest;
|
||||
|
||||
const path = this.getTagsPath(repository);
|
||||
await this.bucket.fastPut({
|
||||
path,
|
||||
contents: Buffer.from(JSON.stringify(tags, null, 2), 'utf-8'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get digest for a tag
|
||||
* @param repository - Repository name
|
||||
* @param tag - Tag name
|
||||
* @returns Digest or null if tag doesn't exist
|
||||
*/
|
||||
public async getTagDigest(repository: string, tag: string): Promise<string | null> {
|
||||
const tags = await this.getTags(repository);
|
||||
return tags[tag] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tags for a repository
|
||||
* @param repository - Repository name
|
||||
* @returns Tag list
|
||||
*/
|
||||
public async listTags(repository: string): Promise<string[]> {
|
||||
const tags = await this.getTags(repository);
|
||||
return Object.keys(tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tag
|
||||
* @param repository - Repository name
|
||||
* @param tag - Tag name
|
||||
*/
|
||||
public async deleteTag(repository: string, tag: string): Promise<void> {
|
||||
const tags = await this.getTags(repository);
|
||||
delete tags[tag];
|
||||
|
||||
const path = this.getTagsPath(repository);
|
||||
await this.bucket.fastPut({
|
||||
path,
|
||||
contents: Buffer.from(JSON.stringify(tags, null, 2), 'utf-8'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all manifests that reference a specific digest (referrers API)
|
||||
* @param repository - Repository name
|
||||
* @param digest - Subject digest
|
||||
* @returns Array of manifest digests
|
||||
*/
|
||||
public async getReferrers(repository: string, digest: string): Promise<string[]> {
|
||||
// This is a simplified implementation
|
||||
// In production, you'd want to maintain an index
|
||||
const referrersPath = this.getReferrersPath(repository, digest);
|
||||
try {
|
||||
const data = await this.bucket.fastGet({ path: referrersPath });
|
||||
const referrers = JSON.parse(data.toString('utf-8'));
|
||||
return referrers;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a referrer relationship
|
||||
* @param repository - Repository name
|
||||
* @param subjectDigest - Digest being referenced
|
||||
* @param referrerDigest - Digest of the referrer
|
||||
*/
|
||||
public async addReferrer(
|
||||
repository: string,
|
||||
subjectDigest: string,
|
||||
referrerDigest: string
|
||||
): Promise<void> {
|
||||
const referrers = await this.getReferrers(repository, subjectDigest);
|
||||
if (!referrers.includes(referrerDigest)) {
|
||||
referrers.push(referrerDigest);
|
||||
}
|
||||
|
||||
const path = this.getReferrersPath(repository, subjectDigest);
|
||||
await this.bucket.fastPut({
|
||||
path,
|
||||
contents: Buffer.from(JSON.stringify(referrers, null, 2), 'utf-8'),
|
||||
});
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private getBlobPath(digest: string): string {
|
||||
// Remove algorithm prefix for path (sha256:abc -> abc)
|
||||
const hash = digest.split(':')[1];
|
||||
return `blobs/sha256/${hash}`;
|
||||
}
|
||||
|
||||
private getManifestPath(repository: string, digest: string): string {
|
||||
const hash = digest.split(':')[1];
|
||||
return `manifests/${repository}/${hash}`;
|
||||
}
|
||||
|
||||
private getTagsPath(repository: string): string {
|
||||
return `tags/${repository}/tags.json`;
|
||||
}
|
||||
|
||||
private getReferrersPath(repository: string, digest: string): string {
|
||||
const hash = digest.split(':')[1];
|
||||
return `referrers/${repository}/${hash}.json`;
|
||||
}
|
||||
|
||||
private async getTags(repository: string): Promise<{ [tag: string]: string }> {
|
||||
const path = this.getTagsPath(repository);
|
||||
try {
|
||||
const data = await this.bucket.fastGet({ path });
|
||||
return JSON.parse(data.toString('utf-8'));
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private async calculateDigest(data: Buffer): Promise<string> {
|
||||
const crypto = await import('crypto');
|
||||
const hash = crypto.createHash('sha256').update(data).digest('hex');
|
||||
return `sha256:${hash}`;
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,4 @@
|
||||
*/
|
||||
|
||||
export { OciRegistry } from './classes.ociregistry.js';
|
||||
export * from './interfaces.oci.ts';
|
||||
export * from './interfaces.oci.js';
|
||||
|
||||
@@ -1,92 +1,7 @@
|
||||
/**
|
||||
* Interfaces and types for OCI Distribution Specification compliant registry
|
||||
* OCI Distribution Specification specific interfaces
|
||||
*/
|
||||
|
||||
/**
|
||||
* Credentials for authentication
|
||||
*/
|
||||
export interface IRegistryCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions that can be performed on a repository
|
||||
*/
|
||||
export type TRegistryAction = 'pull' | 'push' | 'delete' | '*';
|
||||
|
||||
/**
|
||||
* JWT token structure for OCI registry authentication
|
||||
*/
|
||||
export interface IRegistryToken {
|
||||
/** Issuer */
|
||||
iss: string;
|
||||
/** Subject (user identifier) */
|
||||
sub: string;
|
||||
/** Audience (service name) */
|
||||
aud: string;
|
||||
/** Expiration timestamp */
|
||||
exp: number;
|
||||
/** Not before timestamp */
|
||||
nbf: number;
|
||||
/** Issued at timestamp */
|
||||
iat: number;
|
||||
/** JWT ID */
|
||||
jti?: string;
|
||||
/** Access permissions */
|
||||
access: Array<{
|
||||
type: 'repository' | 'registry';
|
||||
name: string;
|
||||
actions: TRegistryAction[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function for user login - returns JWT token
|
||||
* @param credentials - User credentials
|
||||
* @returns JWT token string
|
||||
*/
|
||||
export type TLoginCallback = (
|
||||
credentials: IRegistryCredentials
|
||||
) => Promise<string>;
|
||||
|
||||
/**
|
||||
* Callback function for authorization check
|
||||
* @param token - JWT token string
|
||||
* @param repository - Repository name (e.g., "library/nginx")
|
||||
* @param action - Action to perform
|
||||
* @returns true if authorized, false otherwise
|
||||
*/
|
||||
export type TAuthCallback = (
|
||||
token: string,
|
||||
repository: string,
|
||||
action: TRegistryAction
|
||||
) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Configuration for the registry
|
||||
*/
|
||||
export interface IRegistryConfig {
|
||||
/** Storage bucket configuration */
|
||||
storage: {
|
||||
accessKey: string;
|
||||
accessSecret: string;
|
||||
endpoint: string;
|
||||
port?: number;
|
||||
useSsl?: boolean;
|
||||
region?: string;
|
||||
bucketName: string;
|
||||
};
|
||||
/** Service name for token authentication */
|
||||
serviceName: string;
|
||||
/** Token realm (authorization server URL) */
|
||||
tokenRealm: string;
|
||||
/** Login callback */
|
||||
loginCallback: TLoginCallback;
|
||||
/** Authorization callback */
|
||||
authCallback: TAuthCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* OCI manifest structure
|
||||
*/
|
||||
@@ -175,17 +90,6 @@ export interface IReferrersResponse {
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry error response
|
||||
*/
|
||||
export interface IRegistryError {
|
||||
errors: Array<{
|
||||
code: string;
|
||||
message: string;
|
||||
detail?: any;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination options for listing
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user