Files
onebox/ts/classes/registry.ts

323 lines
9.3 KiB
TypeScript

/**
* 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<void> {
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<Response> {
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<string, string> = {};
req.headers.forEach((value, key) => {
headers[key] = value;
});
const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
// Read body for non-GET requests
// smartregistry v2.2.0 handles Uint8Array natively for proper digest calculation
let body: Uint8Array | undefined;
if (req.method !== 'GET' && req.method !== 'HEAD') {
const bodyData = await req.arrayBuffer();
if (bodyData.byteLength > 0) {
body = new Uint8Array(bodyData);
}
}
// smartregistry v2.2.0 handles JWT tokens natively and supports Uint8Array body
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<string[]> {
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<string | null> {
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<void> {
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<string> {
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<void> {
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');
}
}