2025-11-24 01:31:15 +00:00
|
|
|
/**
|
|
|
|
|
* 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';
|
2025-11-25 08:25:54 +00:00
|
|
|
import { getErrorMessage } from '../utils/error.ts';
|
2025-11-24 01:31:15 +00:00
|
|
|
|
|
|
|
|
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,
|
2025-11-25 08:34:10 +00:00
|
|
|
address: '0.0.0.0',
|
2025-11-24 01:31:15 +00:00
|
|
|
},
|
|
|
|
|
storage: {
|
2025-11-25 08:34:10 +00:00
|
|
|
directory: dataDir,
|
2025-11-24 01:31:15 +00:00
|
|
|
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,
|
2025-11-25 08:34:10 +00:00
|
|
|
tokenStore: 'memory',
|
|
|
|
|
npmTokens: {
|
|
|
|
|
enabled: false,
|
|
|
|
|
},
|
2025-11-24 01:31:15 +00:00
|
|
|
ociTokens: {
|
|
|
|
|
enabled: true,
|
2025-11-25 19:46:18 +00:00
|
|
|
realm: 'http://localhost:3000/v2/token',
|
2025-11-24 01:31:15 +00:00
|
|
|
service: 'onebox-registry',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
oci: {
|
|
|
|
|
enabled: true,
|
2025-11-25 19:46:18 +00:00
|
|
|
basePath: '', // Empty basePath - OCI paths are passed directly as /v2/...
|
2025-11-24 01:31:15 +00:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await this.registry.init();
|
|
|
|
|
|
|
|
|
|
this.isInitialized = true;
|
|
|
|
|
logger.success('Onebox Registry initialized successfully');
|
|
|
|
|
} catch (error) {
|
2025-11-25 08:25:54 +00:00
|
|
|
logger.error(`Failed to initialize registry: ${getErrorMessage(error)}`);
|
2025-11-24 01:31:15 +00:00
|
|
|
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 {
|
2025-11-25 19:46:18 +00:00
|
|
|
// 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
|
|
|
|
|
// 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,
|
|
|
|
|
});
|
2025-11-24 01:31:15 +00:00
|
|
|
} catch (error) {
|
2025-11-25 08:25:54 +00:00
|
|
|
logger.error(`Registry request error: ${getErrorMessage(error)}`);
|
2025-11-24 01:31:15 +00:00
|
|
|
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) {
|
2025-11-25 08:25:54 +00:00
|
|
|
logger.warn(`Failed to get tags for ${repository}: ${getErrorMessage(error)}`);
|
2025-11-24 01:31:15 +00:00
|
|
|
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 {
|
2025-11-24 19:52:35 +00:00
|
|
|
// Check if getManifest method exists (API may have changed)
|
|
|
|
|
if (typeof this.registry.getManifest !== 'function') {
|
|
|
|
|
// Method not available in current API version
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-24 01:31:15 +00:00
|
|
|
const manifest = await this.registry.getManifest(repository, tag);
|
|
|
|
|
if (manifest && manifest.digest) {
|
|
|
|
|
return manifest.digest;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
} catch (error) {
|
2025-11-24 19:52:35 +00:00
|
|
|
// Only log if it's not a "not a function" error
|
2025-11-25 08:25:54 +00:00
|
|
|
const errMsg = getErrorMessage(error);
|
|
|
|
|
if (!errMsg.includes('not a function')) {
|
|
|
|
|
logger.warn(`Failed to get digest for ${repository}:${tag}: ${errMsg}`);
|
2025-11-24 19:52:35 +00:00
|
|
|
}
|
2025-11-24 01:31:15 +00:00
|
|
|
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) {
|
2025-11-25 08:25:54 +00:00
|
|
|
logger.error(`Failed to delete image ${repository}:${tag}: ${getErrorMessage(error)}`);
|
2025-11-24 01:31:15 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-25 19:46:18 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-24 01:31:15 +00:00
|
|
|
/**
|
|
|
|
|
* 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) {
|
2025-11-25 08:25:54 +00:00
|
|
|
logger.error(`Error stopping smarts3: ${getErrorMessage(error)}`);
|
2025-11-24 01:31:15 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.isInitialized = false;
|
|
|
|
|
logger.info('Registry stopped');
|
|
|
|
|
}
|
|
|
|
|
}
|