/** * Onebox Registry Manager * * Manages the local Docker registry using: * - @push.rocks/smarts3 (S3-compatible server with filesystem storage) * - @push.rocks/smartregistry (OCI-compliant Docker registry) */ import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; export class RegistryManager { private s3Server: any = null; private registry: any = null; private jwtSecret: string; private baseUrl: string; private isInitialized = false; constructor(private options: { dataDir?: string; port?: number; baseUrl?: string; } = {}) { this.jwtSecret = this.getJwtSecret(); this.baseUrl = options.baseUrl || 'localhost:5000'; } /** * Initialize the registry (start smarts3 and smartregistry) */ async init(): Promise { if (this.isInitialized) { logger.warn('Registry already initialized'); return; } try { const dataDir = this.options.dataDir || './.nogit/registry-data'; const port = this.options.port || 4000; logger.info(`Starting smarts3 server on port ${port}...`); // 1. Start smarts3 server (S3-compatible storage with filesystem backend) this.s3Server = await plugins.smarts3.Smarts3.createAndStart({ server: { port: port, address: '0.0.0.0', }, storage: { directory: dataDir, cleanSlate: false, // Preserve data across restarts }, }); logger.success(`smarts3 server started on port ${port}`); // 2. Configure smartregistry to use smarts3 logger.info('Initializing smartregistry...'); this.registry = new plugins.smartregistry.SmartRegistry({ storage: { endpoint: 'localhost', port: port, accessKey: 'onebox', // smarts3 doesn't validate credentials accessSecret: 'onebox', useSsl: false, region: 'us-east-1', bucketName: 'onebox-registry', }, auth: { jwtSecret: this.jwtSecret, tokenStore: 'memory', npmTokens: { enabled: false, }, ociTokens: { enabled: true, realm: 'http://localhost:3000/v2/token', service: 'onebox-registry', }, }, oci: { enabled: true, basePath: '', // Empty basePath - OCI paths are passed directly as /v2/... }, }); await this.registry.init(); this.isInitialized = true; logger.success('Onebox Registry initialized successfully'); } catch (error) { logger.error(`Failed to initialize registry: ${getErrorMessage(error)}`); throw error; } } /** * Handle incoming HTTP requests to the registry */ async handleRequest(req: Request): Promise { if (!this.isInitialized) { return new Response('Registry not initialized', { status: 503 }); } try { // Convert native Request to IRequestContext format expected by smartregistry const url = new URL(req.url); const headers: Record = {}; req.headers.forEach((value, key) => { headers[key] = value; }); const query: Record = {}; url.searchParams.forEach((value, key) => { query[key] = value; }); // Read body for non-GET requests // IMPORTANT: smartregistry expects Buffer (not Uint8Array) for proper digest calculation // Buffer.isBuffer(Uint8Array) returns false, causing JSON.stringify which corrupts the data let body: Buffer | undefined; if (req.method !== 'GET' && req.method !== 'HEAD') { const bodyData = await req.arrayBuffer(); if (bodyData.byteLength > 0) { body = Buffer.from(bodyData); } } // smartregistry v2.0.0 handles JWT tokens natively - no decoding needed // Pass rawBody for content-addressable operations (manifest push needs exact bytes for digest) const context = { method: req.method, path: url.pathname, headers, query, body, rawBody: body, // smartregistry uses rawBody for digest calculation }; const result = await this.registry.handleRequest(context); // Log the result for debugging logger.info(`Registry response: status=${result.status}, headers=${JSON.stringify(result.headers)}`); // smartregistry v2.0.0 now properly includes WWW-Authenticate headers on 401 responses // Convert IResponse back to native Response const responseHeaders = new Headers(result.headers || {}); let responseBody: BodyInit | null = null; if (result.body !== undefined) { if (result.body instanceof Uint8Array) { responseBody = result.body; } else if (typeof result.body === 'string') { responseBody = result.body; } else { responseBody = JSON.stringify(result.body); if (!responseHeaders.has('Content-Type')) { responseHeaders.set('Content-Type', 'application/json'); } } } return new Response(responseBody, { status: result.status, headers: responseHeaders, }); } catch (error) { logger.error(`Registry request error: ${getErrorMessage(error)}`); return new Response('Internal registry error', { status: 500 }); } } /** * Get all tags for a repository */ async getImageTags(repository: string): Promise { if (!this.isInitialized) { throw new Error('Registry not initialized'); } try { const tags = await this.registry.getTags(repository); return tags || []; } catch (error) { logger.warn(`Failed to get tags for ${repository}: ${getErrorMessage(error)}`); return []; } } /** * Get the manifest digest for a specific image tag */ async getImageDigest(repository: string, tag: string): Promise { if (!this.isInitialized) { throw new Error('Registry not initialized'); } try { // Check if getManifest method exists (API may have changed) if (typeof this.registry.getManifest !== 'function') { // Method not available in current API version return null; } const manifest = await this.registry.getManifest(repository, tag); if (manifest && manifest.digest) { return manifest.digest; } return null; } catch (error) { // Only log if it's not a "not a function" error const errMsg = getErrorMessage(error); if (!errMsg.includes('not a function')) { logger.warn(`Failed to get digest for ${repository}:${tag}: ${errMsg}`); } return null; } } /** * Delete an image by tag */ async deleteImage(repository: string, tag: string): Promise { if (!this.isInitialized) { throw new Error('Registry not initialized'); } try { await this.registry.deleteManifest(repository, tag); logger.info(`Deleted image ${repository}:${tag}`); } catch (error) { logger.error(`Failed to delete image ${repository}:${tag}: ${getErrorMessage(error)}`); throw error; } } /** * Get or generate the JWT secret for token signing */ private getJwtSecret(): string { // In production, this should be stored securely // For now, use a consistent secret stored in environment or generate one const secret = Deno.env.get('REGISTRY_JWT_SECRET'); if (secret) { return secret; } // Generate a random secret (this will be different on each restart) // In production, you'd want to persist this const randomSecret = crypto.randomUUID() + crypto.randomUUID(); logger.warn('Using generated JWT secret (will be different on restart)'); logger.warn('Set REGISTRY_JWT_SECRET environment variable for persistence'); return randomSecret; } /** * Get the auth manager from the registry */ getAuthManager(): any { if (!this.isInitialized) { throw new Error('Registry not initialized'); } return this.registry.getAuthManager(); } /** * Create an OCI token for Docker authentication * @param repository - Repository name (e.g., 'hello-world') or '*' for all * @param actions - Actions to allow: 'push', 'pull', or both * @param expiresIn - Token expiry in seconds (default: 3600) */ async createOciToken( repository: string = '*', actions: ('push' | 'pull')[] = ['push', 'pull'], expiresIn: number = 3600 ): Promise { if (!this.isInitialized) { throw new Error('Registry not initialized'); } const authManager = this.registry.getAuthManager(); // Create scopes for the token const scopes = actions.map(action => `oci:repository:${repository}:${action}`); // Create OCI token with scopes const token = await authManager.createOciToken('onebox-system', scopes, expiresIn); return token; } /** * Get the registry base URL */ getBaseUrl(): string { return this.baseUrl; } /** * Get the full image name for a service */ getImageName(serviceName: string, tag: string = 'latest'): string { return `${this.baseUrl}/${serviceName}:${tag}`; } /** * Stop the registry and smarts3 server */ async stop(): Promise { if (this.s3Server) { try { await this.s3Server.stop(); logger.info('smarts3 server stopped'); } catch (error) { logger.error(`Error stopping smarts3: ${getErrorMessage(error)}`); } } this.isInitialized = false; logger.info('Registry stopped'); } }