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 { 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 { 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 { const path = this.getBlobPath(digest); return await this.bucket.fastExists({ path }); } /** * Delete a blob * @param digest - Content digest */ public async deleteBlob(digest: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // 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 { 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 { const crypto = await import('crypto'); const hash = crypto.createHash('sha256').update(data).digest('hex'); return `sha256:${hash}`; } }