feat: Implement platform service providers for MinIO and MongoDB
- Added base interface and abstract class for platform service providers. - Created MinIOProvider class for S3-compatible storage with deployment, provisioning, and deprovisioning functionalities. - Implemented MongoDBProvider class for MongoDB service with similar capabilities. - Introduced error handling utilities for better error management. - Developed TokensComponent for managing registry tokens in the UI, including creation, deletion, and display of tokens.
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import { OneboxDatabase } from './database.ts';
|
||||
|
||||
export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
||||
@@ -27,7 +28,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
||||
await Deno.mkdir(this.certBasePath, { recursive: true });
|
||||
logger.info(`Certificate manager initialized (path: ${this.certBasePath})`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to initialize certificate manager: ${error.message}`);
|
||||
logger.error(`Failed to initialize certificate manager: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -56,7 +57,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
||||
|
||||
return cert;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to retrieve certificate for ${domainName}: ${error.message}`);
|
||||
logger.warn(`Failed to retrieve certificate for ${domainName}: ${getErrorMessage(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -110,7 +111,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
||||
|
||||
logger.success(`Certificate stored for ${domain}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to store certificate for ${cert.domainName}: ${error.message}`);
|
||||
logger.error(`Failed to store certificate for ${cert.domainName}: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -128,7 +129,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
||||
try {
|
||||
await Deno.remove(domainPath, { recursive: true });
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete PEM files for ${domainName}: ${error.message}`);
|
||||
logger.warn(`Failed to delete PEM files for ${domainName}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
@@ -137,7 +138,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
||||
logger.info(`Certificate deleted for ${domainName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete certificate for ${domainName}: ${error.message}`);
|
||||
logger.error(`Failed to delete certificate for ${domainName}: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -163,7 +164,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
||||
|
||||
logger.warn('All certificates wiped');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to wipe certificates: ${error.message}`);
|
||||
logger.error(`Failed to wipe certificates: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -175,7 +176,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
||||
try {
|
||||
return await Deno.readTextFile(path);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read PEM file ${path}: ${error.message}`);
|
||||
throw new Error(`Failed to read PEM file ${path}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { projectInfo } from '../info.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import type { Onebox } from './onebox.ts';
|
||||
|
||||
// PID file constants
|
||||
@@ -72,7 +73,7 @@ export class OneboxDaemon {
|
||||
logger.success('Onebox daemon service installed');
|
||||
logger.info('Start with: sudo systemctl start smartdaemon_onebox');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to install daemon service: ${error.message}`);
|
||||
logger.error(`Failed to install daemon service: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -89,7 +90,8 @@ export class OneboxDaemon {
|
||||
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
|
||||
}
|
||||
|
||||
const service = await this.smartdaemon.getService('onebox');
|
||||
const services = await this.smartdaemon.systemdManager.getServices();
|
||||
const service = services.find(s => s.name === 'onebox');
|
||||
|
||||
if (service) {
|
||||
await service.stop();
|
||||
@@ -99,7 +101,7 @@ export class OneboxDaemon {
|
||||
|
||||
logger.success('Onebox daemon service uninstalled');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to uninstall daemon service: ${error.message}`);
|
||||
logger.error(`Failed to uninstall daemon service: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -137,7 +139,7 @@ export class OneboxDaemon {
|
||||
// Keep process alive
|
||||
await this.keepAlive();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start daemon: ${error.message}`);
|
||||
logger.error(`Failed to start daemon: ${getErrorMessage(error)}`);
|
||||
this.running = false;
|
||||
throw error;
|
||||
}
|
||||
@@ -167,7 +169,7 @@ export class OneboxDaemon {
|
||||
|
||||
logger.success('Onebox daemon stopped');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop daemon: ${error.message}`);
|
||||
logger.error(`Failed to stop daemon: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -229,7 +231,7 @@ export class OneboxDaemon {
|
||||
|
||||
logger.debug('Monitoring tick complete');
|
||||
} catch (error) {
|
||||
logger.error(`Monitoring tick failed: ${error.message}`);
|
||||
logger.error(`Monitoring tick failed: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,12 +259,12 @@ export class OneboxDaemon {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to collect metrics for ${service.name}: ${error.message}`);
|
||||
logger.debug(`Failed to collect metrics for ${service.name}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to collect metrics: ${error.message}`);
|
||||
logger.error(`Failed to collect metrics: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +279,7 @@ export class OneboxDaemon {
|
||||
|
||||
await this.oneboxRef.ssl.renewExpiring();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to check SSL expiration: ${error.message}`);
|
||||
logger.error(`Failed to check SSL expiration: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +290,7 @@ export class OneboxDaemon {
|
||||
try {
|
||||
await this.oneboxRef.certRequirementManager.processPendingRequirements();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to process cert requirements: ${error.message}`);
|
||||
logger.error(`Failed to process cert requirements: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +301,7 @@ export class OneboxDaemon {
|
||||
try {
|
||||
await this.oneboxRef.certRequirementManager.checkCertificateRenewal();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to check certificate renewal: ${error.message}`);
|
||||
logger.error(`Failed to check certificate renewal: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +312,7 @@ export class OneboxDaemon {
|
||||
try {
|
||||
await this.oneboxRef.certRequirementManager.cleanupOldCertificates();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to cleanup old certificates: ${error.message}`);
|
||||
logger.error(`Failed to cleanup old certificates: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +335,7 @@ export class OneboxDaemon {
|
||||
await this.oneboxRef.cloudflareDomainSync.syncZones();
|
||||
this.lastDomainSync = now;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to sync Cloudflare domains: ${error.message}`);
|
||||
logger.error(`Failed to sync Cloudflare domains: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,7 +390,7 @@ export class OneboxDaemon {
|
||||
this.pidFilePath = FALLBACK_PID_FILE;
|
||||
logger.debug(`PID file written: ${FALLBACK_PID_FILE}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to write PID file: ${error.message}`);
|
||||
logger.warn(`Failed to write PID file: ${getErrorMessage(error)}`);
|
||||
// Non-fatal - daemon can still run
|
||||
}
|
||||
}
|
||||
@@ -402,7 +404,7 @@ export class OneboxDaemon {
|
||||
logger.debug(`PID file removed: ${this.pidFilePath}`);
|
||||
} catch (error) {
|
||||
// Ignore errors - file might not exist
|
||||
logger.debug(`Could not remove PID file: ${error.message}`);
|
||||
logger.debug(`Could not remove PID file: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as plugins from '../plugins.ts';
|
||||
import type {
|
||||
IService,
|
||||
IRegistry,
|
||||
IRegistryToken,
|
||||
INginxConfig,
|
||||
ISslCertificate,
|
||||
IDnsRecord,
|
||||
@@ -13,11 +14,22 @@ import type {
|
||||
ILogEntry,
|
||||
IUser,
|
||||
ISetting,
|
||||
IPlatformService,
|
||||
IPlatformResource,
|
||||
IPlatformRequirements,
|
||||
TPlatformServiceType,
|
||||
IDomain,
|
||||
ICertificate,
|
||||
ICertRequirement,
|
||||
} from '../types.ts';
|
||||
|
||||
// Type alias for sqlite bind parameters
|
||||
type BindValue = string | number | bigint | boolean | null | undefined | Uint8Array;
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
|
||||
export class OneboxDatabase {
|
||||
private db: plugins.sqlite.DB | null = null;
|
||||
private db: InstanceType<typeof plugins.sqlite.DB> | null = null;
|
||||
private dbPath: string;
|
||||
|
||||
constructor(dbPath = './.nogit/onebox.db') {
|
||||
@@ -43,7 +55,7 @@ export class OneboxDatabase {
|
||||
// Run migrations if needed
|
||||
await this.runMigrations();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to initialize database: ${error.message}`);
|
||||
logger.error(`Failed to initialize database: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -447,28 +459,40 @@ export class OneboxDatabase {
|
||||
|
||||
// 4. Migrate existing ssl_certificates data
|
||||
// Extract unique base domains from existing certificates
|
||||
const existingCerts = this.query('SELECT * FROM ssl_certificates');
|
||||
interface OldSslCert {
|
||||
id?: number;
|
||||
domain?: string;
|
||||
cert_path?: string;
|
||||
key_path?: string;
|
||||
full_chain_path?: string;
|
||||
expiry_date?: number;
|
||||
issuer?: string;
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
[key: number]: unknown; // Allow array-style access as fallback
|
||||
}
|
||||
const existingCerts = this.query<OldSslCert>('SELECT * FROM ssl_certificates');
|
||||
|
||||
const now = Date.now();
|
||||
const domainMap = new Map<string, number>();
|
||||
|
||||
// Create domain entries for each unique base domain
|
||||
for (const cert of existingCerts) {
|
||||
const domain = String(cert.domain ?? cert[1]);
|
||||
const domain = String(cert.domain ?? (cert as Record<number, unknown>)[1]);
|
||||
if (!domainMap.has(domain)) {
|
||||
this.query(
|
||||
'INSERT INTO domains (domain, dns_provider, is_obsolete, default_wildcard, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[domain, null, 0, 1, now, now]
|
||||
);
|
||||
const result = this.query('SELECT last_insert_rowid() as id');
|
||||
const domainId = result[0].id ?? result[0][0];
|
||||
const result = this.query<{ id?: number; [key: number]: unknown }>('SELECT last_insert_rowid() as id');
|
||||
const domainId = result[0].id ?? (result[0] as Record<number, unknown>)[0];
|
||||
domainMap.set(domain, Number(domainId));
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate certificates to new table
|
||||
for (const cert of existingCerts) {
|
||||
const domain = String(cert.domain ?? cert[1]);
|
||||
const domain = String(cert.domain ?? (cert as Record<number, unknown>)[1]);
|
||||
const domainId = domainMap.get(domain);
|
||||
|
||||
this.query(
|
||||
@@ -480,14 +504,14 @@ export class OneboxDatabase {
|
||||
domainId,
|
||||
domain,
|
||||
0, // We don't know if it's wildcard, default to false
|
||||
String(cert.cert_path ?? cert[2]),
|
||||
String(cert.key_path ?? cert[3]),
|
||||
String(cert.full_chain_path ?? cert[4]),
|
||||
Number(cert.expiry_date ?? cert[5]),
|
||||
String(cert.issuer ?? cert[6]),
|
||||
String(cert.cert_path ?? (cert as Record<number, unknown>)[2]),
|
||||
String(cert.key_path ?? (cert as Record<number, unknown>)[3]),
|
||||
String(cert.full_chain_path ?? (cert as Record<number, unknown>)[4]),
|
||||
Number(cert.expiry_date ?? (cert as Record<number, unknown>)[5]),
|
||||
String(cert.issuer ?? (cert as Record<number, unknown>)[6]),
|
||||
1, // Assume valid
|
||||
Number(cert.created_at ?? cert[7]),
|
||||
Number(cert.updated_at ?? cert[8])
|
||||
Number(cert.created_at ?? (cert as Record<number, unknown>)[7]),
|
||||
Number(cert.updated_at ?? (cert as Record<number, unknown>)[8])
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -534,9 +558,143 @@ export class OneboxDatabase {
|
||||
this.setMigrationVersion(4);
|
||||
logger.success('Migration 4 completed: Onebox Registry columns added to services table');
|
||||
}
|
||||
|
||||
// Migration 5: Registry tokens table
|
||||
const version5 = this.getMigrationVersion();
|
||||
if (version5 < 5) {
|
||||
logger.info('Running migration 5: Creating registry_tokens table...');
|
||||
|
||||
this.query(`
|
||||
CREATE TABLE registry_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_type TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
expires_at REAL,
|
||||
created_at REAL NOT NULL,
|
||||
last_used_at REAL,
|
||||
created_by TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indices for performance
|
||||
this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_type ON registry_tokens(token_type)');
|
||||
this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_hash ON registry_tokens(token_hash)');
|
||||
|
||||
this.setMigrationVersion(5);
|
||||
logger.success('Migration 5 completed: Registry tokens table created');
|
||||
}
|
||||
|
||||
// Migration 6: Drop registry_token column from services table (replaced by registry_tokens table)
|
||||
const version6 = this.getMigrationVersion();
|
||||
if (version6 < 6) {
|
||||
logger.info('Running migration 6: Dropping registry_token column from services table...');
|
||||
|
||||
// SQLite doesn't support DROP COLUMN directly, so we need to recreate the table
|
||||
// Create new table without registry_token
|
||||
this.query(`
|
||||
CREATE TABLE services_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
image TEXT NOT NULL,
|
||||
registry TEXT,
|
||||
env_vars TEXT,
|
||||
port INTEGER NOT NULL,
|
||||
domain TEXT,
|
||||
container_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
use_onebox_registry INTEGER DEFAULT 0,
|
||||
registry_repository TEXT,
|
||||
registry_image_tag TEXT DEFAULT 'latest',
|
||||
auto_update_on_push INTEGER DEFAULT 0,
|
||||
image_digest TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Copy data (excluding registry_token)
|
||||
this.query(`
|
||||
INSERT INTO services_new (
|
||||
id, name, image, registry, env_vars, port, domain, container_id, status,
|
||||
created_at, updated_at, use_onebox_registry, registry_repository,
|
||||
registry_image_tag, auto_update_on_push, image_digest
|
||||
)
|
||||
SELECT
|
||||
id, name, image, registry, env_vars, port, domain, container_id, status,
|
||||
created_at, updated_at, use_onebox_registry, registry_repository,
|
||||
registry_image_tag, auto_update_on_push, image_digest
|
||||
FROM services
|
||||
`);
|
||||
|
||||
// Drop old table
|
||||
this.query('DROP TABLE services');
|
||||
|
||||
// Rename new table
|
||||
this.query('ALTER TABLE services_new RENAME TO services');
|
||||
|
||||
// Recreate indices
|
||||
this.query('CREATE INDEX IF NOT EXISTS idx_services_name ON services(name)');
|
||||
this.query('CREATE INDEX IF NOT EXISTS idx_services_status ON services(status)');
|
||||
|
||||
this.setMigrationVersion(6);
|
||||
logger.success('Migration 6 completed: registry_token column dropped from services table');
|
||||
}
|
||||
|
||||
// Migration 7: Platform services tables
|
||||
const version7 = this.getMigrationVersion();
|
||||
if (version7 < 7) {
|
||||
logger.info('Running migration 7: Creating platform services tables...');
|
||||
|
||||
// Create platform_services table
|
||||
this.query(`
|
||||
CREATE TABLE platform_services (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'stopped',
|
||||
container_id TEXT,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
admin_credentials_encrypted TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Create platform_resources table
|
||||
this.query(`
|
||||
CREATE TABLE platform_resources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform_service_id INTEGER NOT NULL,
|
||||
service_id INTEGER NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_name TEXT NOT NULL,
|
||||
credentials_encrypted TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
FOREIGN KEY (platform_service_id) REFERENCES platform_services(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Add platform_requirements column to services table
|
||||
this.query(`
|
||||
ALTER TABLE services ADD COLUMN platform_requirements TEXT DEFAULT '{}'
|
||||
`);
|
||||
|
||||
// Create indices
|
||||
this.query('CREATE INDEX IF NOT EXISTS idx_platform_services_type ON platform_services(type)');
|
||||
this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_service ON platform_resources(service_id)');
|
||||
this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_platform ON platform_resources(platform_service_id)');
|
||||
|
||||
this.setMigrationVersion(7);
|
||||
logger.success('Migration 7 completed: Platform services tables created');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Migration failed: ${error.message}`);
|
||||
logger.error(`Stack: ${error.stack}`);
|
||||
logger.error(`Migration failed: ${getErrorMessage(error)}`);
|
||||
if (error instanceof Error && error.stack) {
|
||||
logger.error(`Stack: ${error.stack}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -548,14 +706,14 @@ export class OneboxDatabase {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
try {
|
||||
const result = this.query('SELECT MAX(version) as version FROM migrations');
|
||||
const result = this.query<{ version?: number | null; [key: number]: unknown }>('SELECT MAX(version) as version FROM migrations');
|
||||
if (result.length === 0) return 0;
|
||||
|
||||
// Handle both array and object access patterns
|
||||
const versionValue = result[0].version ?? result[0][0];
|
||||
const versionValue = result[0].version ?? (result[0] as Record<number, unknown>)[0];
|
||||
return versionValue !== null && versionValue !== undefined ? Number(versionValue) : 0;
|
||||
} catch (error) {
|
||||
logger.warn(`Error getting migration version: ${error.message}, defaulting to 0`);
|
||||
logger.warn(`Error getting migration version: ${getErrorMessage(error)}, defaulting to 0`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -587,7 +745,7 @@ export class OneboxDatabase {
|
||||
/**
|
||||
* Execute a raw query
|
||||
*/
|
||||
query<T = unknown[]>(sql: string, params: unknown[] = []): T[] {
|
||||
query<T = Record<string, unknown>>(sql: string, params: BindValue[] = []): T[] {
|
||||
if (!this.db) {
|
||||
const error = new Error('Database not initialized');
|
||||
console.error('Database access before initialization!');
|
||||
@@ -621,8 +779,8 @@ export class OneboxDatabase {
|
||||
`INSERT INTO services (
|
||||
name, image, registry, env_vars, port, domain, container_id, status,
|
||||
created_at, updated_at,
|
||||
use_onebox_registry, registry_repository, registry_token, registry_image_tag,
|
||||
auto_update_on_push, image_digest
|
||||
use_onebox_registry, registry_repository, registry_image_tag,
|
||||
auto_update_on_push, image_digest, platform_requirements
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
service.name,
|
||||
@@ -637,10 +795,10 @@ export class OneboxDatabase {
|
||||
now,
|
||||
service.useOneboxRegistry ? 1 : 0,
|
||||
service.registryRepository || null,
|
||||
service.registryToken || null,
|
||||
service.registryImageTag || 'latest',
|
||||
service.autoUpdateOnPush ? 1 : 0,
|
||||
service.imageDigest || null,
|
||||
JSON.stringify(service.platformRequirements || {}),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -717,10 +875,6 @@ export class OneboxDatabase {
|
||||
fields.push('registry_repository = ?');
|
||||
values.push(updates.registryRepository);
|
||||
}
|
||||
if (updates.registryToken !== undefined) {
|
||||
fields.push('registry_token = ?');
|
||||
values.push(updates.registryToken);
|
||||
}
|
||||
if (updates.registryImageTag !== undefined) {
|
||||
fields.push('registry_image_tag = ?');
|
||||
values.push(updates.registryImageTag);
|
||||
@@ -733,6 +887,10 @@ export class OneboxDatabase {
|
||||
fields.push('image_digest = ?');
|
||||
values.push(updates.imageDigest);
|
||||
}
|
||||
if (updates.platformRequirements !== undefined) {
|
||||
fields.push('platform_requirements = ?');
|
||||
values.push(JSON.stringify(updates.platformRequirements));
|
||||
}
|
||||
|
||||
fields.push('updated_at = ?');
|
||||
values.push(Date.now());
|
||||
@@ -754,11 +912,23 @@ export class OneboxDatabase {
|
||||
try {
|
||||
envVars = JSON.parse(String(envVarsRaw));
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to parse env_vars for service: ${e.message}`);
|
||||
logger.warn(`Failed to parse env_vars for service: ${getErrorMessage(e)}`);
|
||||
envVars = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle platform_requirements JSON parsing safely
|
||||
let platformRequirements: IPlatformRequirements | undefined;
|
||||
const platformReqRaw = row.platform_requirements;
|
||||
if (platformReqRaw && platformReqRaw !== 'undefined' && platformReqRaw !== 'null' && platformReqRaw !== '{}') {
|
||||
try {
|
||||
platformRequirements = JSON.parse(String(platformReqRaw));
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to parse platform_requirements for service: ${getErrorMessage(e)}`);
|
||||
platformRequirements = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(row.id || row[0]),
|
||||
name: String(row.name || row[1]),
|
||||
@@ -774,10 +944,11 @@ export class OneboxDatabase {
|
||||
// Onebox Registry fields
|
||||
useOneboxRegistry: row.use_onebox_registry ? Boolean(row.use_onebox_registry) : undefined,
|
||||
registryRepository: row.registry_repository ? String(row.registry_repository) : undefined,
|
||||
registryToken: row.registry_token ? String(row.registry_token) : undefined,
|
||||
registryImageTag: row.registry_image_tag ? String(row.registry_image_tag) : undefined,
|
||||
autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined,
|
||||
imageDigest: row.image_digest ? String(row.image_digest) : undefined,
|
||||
// Platform service requirements
|
||||
platformRequirements,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1392,4 +1563,279 @@ export class OneboxDatabase {
|
||||
updatedAt: Number(row.updated_at || row[7]),
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Registry Tokens ============
|
||||
|
||||
createRegistryToken(token: Omit<IRegistryToken, 'id'>): IRegistryToken {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const scopeJson = Array.isArray(token.scope) ? JSON.stringify(token.scope) : token.scope;
|
||||
|
||||
this.query(
|
||||
`INSERT INTO registry_tokens (name, token_hash, token_type, scope, expires_at, created_at, last_used_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
token.name,
|
||||
token.tokenHash,
|
||||
token.type,
|
||||
scopeJson,
|
||||
token.expiresAt,
|
||||
token.createdAt,
|
||||
token.lastUsedAt,
|
||||
token.createdBy,
|
||||
]
|
||||
);
|
||||
|
||||
const rows = this.query('SELECT * FROM registry_tokens WHERE id = last_insert_rowid()');
|
||||
return this.rowToRegistryToken(rows[0]);
|
||||
}
|
||||
|
||||
getRegistryTokenById(id: number): IRegistryToken | null {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.query('SELECT * FROM registry_tokens WHERE id = ?', [id]);
|
||||
return rows.length > 0 ? this.rowToRegistryToken(rows[0]) : null;
|
||||
}
|
||||
|
||||
getRegistryTokenByHash(tokenHash: string): IRegistryToken | null {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.query('SELECT * FROM registry_tokens WHERE token_hash = ?', [tokenHash]);
|
||||
return rows.length > 0 ? this.rowToRegistryToken(rows[0]) : null;
|
||||
}
|
||||
|
||||
getAllRegistryTokens(): IRegistryToken[] {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.query('SELECT * FROM registry_tokens ORDER BY created_at DESC');
|
||||
return rows.map((row) => this.rowToRegistryToken(row));
|
||||
}
|
||||
|
||||
getRegistryTokensByType(type: 'global' | 'ci'): IRegistryToken[] {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.query('SELECT * FROM registry_tokens WHERE token_type = ? ORDER BY created_at DESC', [type]);
|
||||
return rows.map((row) => this.rowToRegistryToken(row));
|
||||
}
|
||||
|
||||
updateRegistryTokenLastUsed(id: number): void {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
this.query('UPDATE registry_tokens SET last_used_at = ? WHERE id = ?', [Date.now(), id]);
|
||||
}
|
||||
|
||||
deleteRegistryToken(id: number): void {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
this.query('DELETE FROM registry_tokens WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
private rowToRegistryToken(row: any): IRegistryToken {
|
||||
// Parse scope - it's either 'all' or a JSON array
|
||||
let scope: 'all' | string[];
|
||||
const scopeRaw = row.scope || row[4];
|
||||
if (scopeRaw === 'all') {
|
||||
scope = 'all';
|
||||
} else {
|
||||
try {
|
||||
scope = JSON.parse(String(scopeRaw));
|
||||
} catch {
|
||||
scope = 'all';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(row.id || row[0]),
|
||||
name: String(row.name || row[1]),
|
||||
tokenHash: String(row.token_hash || row[2]),
|
||||
type: String(row.token_type || row[3]) as IRegistryToken['type'],
|
||||
scope,
|
||||
expiresAt: row.expires_at || row[5] ? Number(row.expires_at || row[5]) : null,
|
||||
createdAt: Number(row.created_at || row[6]),
|
||||
lastUsedAt: row.last_used_at || row[7] ? Number(row.last_used_at || row[7]) : null,
|
||||
createdBy: String(row.created_by || row[8]),
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Platform Services CRUD ============
|
||||
|
||||
createPlatformService(service: Omit<IPlatformService, 'id'>): IPlatformService {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const now = Date.now();
|
||||
this.query(
|
||||
`INSERT INTO platform_services (name, type, status, container_id, config, admin_credentials_encrypted, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
service.name,
|
||||
service.type,
|
||||
service.status,
|
||||
service.containerId || null,
|
||||
JSON.stringify(service.config),
|
||||
service.adminCredentialsEncrypted || null,
|
||||
now,
|
||||
now,
|
||||
]
|
||||
);
|
||||
|
||||
return this.getPlatformServiceByName(service.name)!;
|
||||
}
|
||||
|
||||
getPlatformServiceByName(name: string): IPlatformService | null {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.query('SELECT * FROM platform_services WHERE name = ?', [name]);
|
||||
return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null;
|
||||
}
|
||||
|
||||
getPlatformServiceById(id: number): IPlatformService | null {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.query('SELECT * FROM platform_services WHERE id = ?', [id]);
|
||||
return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null;
|
||||
}
|
||||
|
||||
getPlatformServiceByType(type: TPlatformServiceType): IPlatformService | null {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.query('SELECT * FROM platform_services WHERE type = ?', [type]);
|
||||
return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null;
|
||||
}
|
||||
|
||||
getAllPlatformServices(): IPlatformService[] {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.query('SELECT * FROM platform_services ORDER BY created_at DESC');
|
||||
return rows.map((row) => this.rowToPlatformService(row));
|
||||
}
|
||||
|
||||
updatePlatformService(id: number, updates: Partial<IPlatformService>): void {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (updates.status !== undefined) {
|
||||
fields.push('status = ?');
|
||||
values.push(updates.status);
|
||||
}
|
||||
if (updates.containerId !== undefined) {
|
||||
fields.push('container_id = ?');
|
||||
values.push(updates.containerId);
|
||||
}
|
||||
if (updates.config !== undefined) {
|
||||
fields.push('config = ?');
|
||||
values.push(JSON.stringify(updates.config));
|
||||
}
|
||||
if (updates.adminCredentialsEncrypted !== undefined) {
|
||||
fields.push('admin_credentials_encrypted = ?');
|
||||
values.push(updates.adminCredentialsEncrypted);
|
||||
}
|
||||
|
||||
fields.push('updated_at = ?');
|
||||
values.push(Date.now());
|
||||
values.push(id);
|
||||
|
||||
this.query(`UPDATE platform_services SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||
}
|
||||
|
||||
deletePlatformService(id: number): void {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
this.query('DELETE FROM platform_services WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
private rowToPlatformService(row: any): IPlatformService {
|
||||
let config = { image: '', port: 0 };
|
||||
const configRaw = row.config;
|
||||
if (configRaw) {
|
||||
try {
|
||||
config = JSON.parse(String(configRaw));
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to parse platform service config: ${getErrorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: String(row.name),
|
||||
type: String(row.type) as TPlatformServiceType,
|
||||
status: String(row.status) as IPlatformService['status'],
|
||||
containerId: row.container_id ? String(row.container_id) : undefined,
|
||||
config,
|
||||
adminCredentialsEncrypted: row.admin_credentials_encrypted ? String(row.admin_credentials_encrypted) : undefined,
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Platform Resources CRUD ============
|
||||
|
||||
createPlatformResource(resource: Omit<IPlatformResource, 'id'>): IPlatformResource {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const now = Date.now();
|
||||
this.query(
|
||||
`INSERT INTO platform_resources (platform_service_id, service_id, resource_type, resource_name, credentials_encrypted, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
resource.platformServiceId,
|
||||
resource.serviceId,
|
||||
resource.resourceType,
|
||||
resource.resourceName,
|
||||
resource.credentialsEncrypted,
|
||||
now,
|
||||
]
|
||||
);
|
||||
|
||||
const rows = this.query('SELECT * FROM platform_resources WHERE id = last_insert_rowid()');
|
||||
return this.rowToPlatformResource(rows[0]);
|
||||
}
|
||||
|
||||
getPlatformResourceById(id: number): IPlatformResource | null {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.query('SELECT * FROM platform_resources WHERE id = ?', [id]);
|
||||
return rows.length > 0 ? this.rowToPlatformResource(rows[0]) : null;
|
||||
}
|
||||
|
||||
getPlatformResourcesByService(serviceId: number): IPlatformResource[] {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.query('SELECT * FROM platform_resources WHERE service_id = ?', [serviceId]);
|
||||
return rows.map((row) => this.rowToPlatformResource(row));
|
||||
}
|
||||
|
||||
getPlatformResourcesByPlatformService(platformServiceId: number): IPlatformResource[] {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.query('SELECT * FROM platform_resources WHERE platform_service_id = ?', [platformServiceId]);
|
||||
return rows.map((row) => this.rowToPlatformResource(row));
|
||||
}
|
||||
|
||||
getAllPlatformResources(): IPlatformResource[] {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const rows = this.query('SELECT * FROM platform_resources ORDER BY created_at DESC');
|
||||
return rows.map((row) => this.rowToPlatformResource(row));
|
||||
}
|
||||
|
||||
deletePlatformResource(id: number): void {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
this.query('DELETE FROM platform_resources WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
deletePlatformResourcesByService(serviceId: number): void {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
this.query('DELETE FROM platform_resources WHERE service_id = ?', [serviceId]);
|
||||
}
|
||||
|
||||
private rowToPlatformResource(row: any): IPlatformResource {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
platformServiceId: Number(row.platform_service_id),
|
||||
serviceId: Number(row.service_id),
|
||||
resourceType: String(row.resource_type) as IPlatformResource['resourceType'],
|
||||
resourceName: String(row.resource_name),
|
||||
credentialsEncrypted: String(row.credentials_encrypted),
|
||||
createdAt: Number(row.created_at),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,4 +841,89 @@ export class OneboxDockerManager {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a platform service container (MongoDB, MinIO, etc.)
|
||||
* Platform containers are long-running infrastructure services
|
||||
*/
|
||||
async createPlatformContainer(options: {
|
||||
name: string;
|
||||
image: string;
|
||||
port: number;
|
||||
env: string[];
|
||||
volumes?: string[];
|
||||
network: string;
|
||||
command?: string[];
|
||||
exposePorts?: number[];
|
||||
}): Promise<string> {
|
||||
try {
|
||||
logger.info(`Creating platform container: ${options.name}`);
|
||||
|
||||
// Check if container already exists
|
||||
const existingContainers = await this.dockerClient!.listContainers();
|
||||
const existing = existingContainers.find((c: any) =>
|
||||
c.Names?.some((n: string) => n === `/${options.name}` || n === options.name)
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
logger.info(`Platform container ${options.name} already exists, removing old container...`);
|
||||
await this.removeContainer(existing.Id, true);
|
||||
}
|
||||
|
||||
// Prepare exposed ports
|
||||
const exposedPorts: Record<string, Record<string, never>> = {};
|
||||
const portBindings: Record<string, Array<{ HostIp: string; HostPort: string }>> = {};
|
||||
|
||||
const portsToExpose = options.exposePorts || [options.port];
|
||||
for (const port of portsToExpose) {
|
||||
exposedPorts[`${port}/tcp`] = {};
|
||||
// Don't bind to host ports by default - services communicate via Docker network
|
||||
portBindings[`${port}/tcp`] = [];
|
||||
}
|
||||
|
||||
// Prepare volume bindings
|
||||
const binds: string[] = options.volumes || [];
|
||||
|
||||
// Create the container
|
||||
const response = await this.dockerClient!.request('POST', `/containers/create?name=${options.name}`, {
|
||||
Image: options.image,
|
||||
Cmd: options.command,
|
||||
Env: options.env,
|
||||
Labels: {
|
||||
'managed-by': 'onebox',
|
||||
'onebox-platform-service': options.name,
|
||||
},
|
||||
ExposedPorts: exposedPorts,
|
||||
HostConfig: {
|
||||
NetworkMode: options.network,
|
||||
RestartPolicy: {
|
||||
Name: 'unless-stopped',
|
||||
},
|
||||
PortBindings: portBindings,
|
||||
Binds: binds,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.statusCode >= 300) {
|
||||
const errorMsg = response.body?.message || `HTTP ${response.statusCode}`;
|
||||
throw new Error(`Failed to create platform container: ${errorMsg}`);
|
||||
}
|
||||
|
||||
const containerID = response.body.Id;
|
||||
logger.info(`Platform container created: ${containerID}`);
|
||||
|
||||
// Start the container
|
||||
const startResponse = await this.dockerClient!.request('POST', `/containers/${containerID}/start`, {});
|
||||
|
||||
if (startResponse.statusCode >= 300 && startResponse.statusCode !== 304) {
|
||||
throw new Error(`Failed to start platform container: HTTP ${startResponse.statusCode}`);
|
||||
}
|
||||
|
||||
logger.success(`Platform container ${options.name} started successfully`);
|
||||
return containerID;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create platform container ${options.name}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
203
ts/classes/encryption.ts
Normal file
203
ts/classes/encryption.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* AES-256-GCM encryption for credential storage
|
||||
*/
|
||||
|
||||
import { logger } from '../logging.ts';
|
||||
|
||||
export class CredentialEncryption {
|
||||
private key: CryptoKey | null = null;
|
||||
private readonly algorithm = 'AES-GCM';
|
||||
private readonly keyLength = 256;
|
||||
private readonly ivLength = 12; // 96 bits for GCM
|
||||
|
||||
/**
|
||||
* Initialize encryption with a key from environment or generate machine-specific key
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
const envKey = Deno.env.get('ONEBOX_ENCRYPTION_KEY');
|
||||
|
||||
if (envKey) {
|
||||
// Use provided key (should be 32 bytes base64 encoded)
|
||||
const keyBytes = this.base64ToBytes(envKey);
|
||||
if (keyBytes.length !== 32) {
|
||||
throw new Error('ONEBOX_ENCRYPTION_KEY must be 32 bytes (256 bits) base64 encoded');
|
||||
}
|
||||
this.key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: this.algorithm },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
logger.log('info', 'Encryption key loaded from environment', 'CredentialEncryption');
|
||||
} else {
|
||||
// Derive key from machine-specific data
|
||||
this.key = await this.deriveKeyFromMachine();
|
||||
logger.log('info', 'Encryption key derived from machine identity', 'CredentialEncryption');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a key from machine-specific information
|
||||
* This ensures the key is consistent across restarts on the same machine
|
||||
*/
|
||||
private async deriveKeyFromMachine(): Promise<CryptoKey> {
|
||||
// Collect machine-specific data
|
||||
const machineData: string[] = [];
|
||||
|
||||
// Hostname
|
||||
try {
|
||||
machineData.push(Deno.hostname());
|
||||
} catch {
|
||||
machineData.push('unknown-host');
|
||||
}
|
||||
|
||||
// Machine ID from /etc/machine-id (Linux) or generate consistent fallback
|
||||
try {
|
||||
const machineId = await Deno.readTextFile('/etc/machine-id');
|
||||
machineData.push(machineId.trim());
|
||||
} catch {
|
||||
// Fallback: use a combination of other identifiers
|
||||
machineData.push('onebox-default-machine-id');
|
||||
}
|
||||
|
||||
// Add a salt
|
||||
machineData.push('onebox-credential-encryption-v1');
|
||||
|
||||
// Create seed from machine data
|
||||
const seed = machineData.join(':');
|
||||
const encoder = new TextEncoder();
|
||||
const seedBytes = encoder.encode(seed);
|
||||
|
||||
// Use PBKDF2 to derive key
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
seedBytes,
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
return await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: encoder.encode('onebox-salt-v1'),
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
baseKey,
|
||||
{ name: this.algorithm, length: this.keyLength },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a credentials object to a base64 string
|
||||
*/
|
||||
async encrypt(data: Record<string, string>): Promise<string> {
|
||||
if (!this.key) {
|
||||
throw new Error('Encryption not initialized. Call init() first.');
|
||||
}
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(this.ivLength));
|
||||
const encoded = new TextEncoder().encode(JSON.stringify(data));
|
||||
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: this.algorithm, iv },
|
||||
this.key,
|
||||
encoded
|
||||
);
|
||||
|
||||
// Combine IV + ciphertext and encode as base64
|
||||
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
||||
combined.set(iv, 0);
|
||||
combined.set(new Uint8Array(ciphertext), iv.length);
|
||||
|
||||
return this.bytesToBase64(combined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a base64 string back to credentials object
|
||||
*/
|
||||
async decrypt(encrypted: string): Promise<Record<string, string>> {
|
||||
if (!this.key) {
|
||||
throw new Error('Encryption not initialized. Call init() first.');
|
||||
}
|
||||
|
||||
const combined = this.base64ToBytes(encrypted);
|
||||
|
||||
// Extract IV and ciphertext
|
||||
const iv = combined.slice(0, this.ivLength);
|
||||
const ciphertext = combined.slice(this.ivLength);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: this.algorithm, iv },
|
||||
this.key,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
const decoded = new TextDecoder().decode(decrypted);
|
||||
return JSON.parse(decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure random password
|
||||
*/
|
||||
generatePassword(length: number = 32): string {
|
||||
// Exclude ambiguous characters (0, O, l, 1, I)
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
||||
const randomBytes = crypto.getRandomValues(new Uint8Array(length));
|
||||
let result = '';
|
||||
for (const byte of randomBytes) {
|
||||
result += chars[byte % chars.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an access key (alphanumeric, uppercase)
|
||||
*/
|
||||
generateAccessKey(length: number = 20): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const randomBytes = crypto.getRandomValues(new Uint8Array(length));
|
||||
let result = '';
|
||||
for (const byte of randomBytes) {
|
||||
result += chars[byte % chars.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secret key (alphanumeric, mixed case)
|
||||
*/
|
||||
generateSecretKey(length: number = 40): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const randomBytes = crypto.getRandomValues(new Uint8Array(length));
|
||||
let result = '';
|
||||
for (const byte of randomBytes) {
|
||||
result += chars[byte % chars.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
private base64ToBytes(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const credentialEncryption = new CredentialEncryption();
|
||||
@@ -7,7 +7,7 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import type { Onebox } from './onebox.ts';
|
||||
import type { IApiResponse } from '../types.ts';
|
||||
import type { IApiResponse, ICreateRegistryTokenRequest, IRegistryTokenView } from '../types.ts';
|
||||
|
||||
export class OneboxHttpServer {
|
||||
private oneboxRef: Onebox;
|
||||
@@ -263,9 +263,28 @@ export class OneboxHttpServer {
|
||||
} else if (path.match(/^\/api\/registry\/tags\/[^/]+$/)) {
|
||||
const serviceName = path.split('/').pop()!;
|
||||
return await this.handleGetRegistryTagsRequest(serviceName);
|
||||
} else if (path.match(/^\/api\/registry\/token\/[^/]+$/)) {
|
||||
const serviceName = path.split('/').pop()!;
|
||||
return await this.handleGetRegistryTokenRequest(serviceName);
|
||||
} else if (path === '/api/registry/tokens' && method === 'GET') {
|
||||
return await this.handleListRegistryTokensRequest(req);
|
||||
} else if (path === '/api/registry/tokens' && method === 'POST') {
|
||||
return await this.handleCreateRegistryTokenRequest(req);
|
||||
} else if (path.match(/^\/api\/registry\/tokens\/\d+$/) && method === 'DELETE') {
|
||||
const tokenId = Number(path.split('/').pop());
|
||||
return await this.handleDeleteRegistryTokenRequest(tokenId);
|
||||
// Platform Services endpoints
|
||||
} else if (path === '/api/platform-services' && method === 'GET') {
|
||||
return await this.handleListPlatformServicesRequest();
|
||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)$/) && method === 'GET') {
|
||||
const type = path.split('/').pop()!;
|
||||
return await this.handleGetPlatformServiceRequest(type);
|
||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)\/start$/) && method === 'POST') {
|
||||
const type = path.split('/')[3];
|
||||
return await this.handleStartPlatformServiceRequest(type);
|
||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)\/stop$/) && method === 'POST') {
|
||||
const type = path.split('/')[3];
|
||||
return await this.handleStopPlatformServiceRequest(type);
|
||||
} else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') {
|
||||
const serviceName = path.split('/')[3];
|
||||
return await this.handleGetServicePlatformResourcesRequest(serviceName);
|
||||
} else {
|
||||
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
|
||||
}
|
||||
@@ -1032,6 +1051,183 @@ export class OneboxHttpServer {
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Platform Services Endpoints ============
|
||||
|
||||
private async handleListPlatformServicesRequest(): Promise<Response> {
|
||||
try {
|
||||
const platformServices = this.oneboxRef.platformServices.getAllPlatformServices();
|
||||
const providers = this.oneboxRef.platformServices.getAllProviders();
|
||||
|
||||
// Build response with provider info
|
||||
const result = providers.map((provider) => {
|
||||
const service = platformServices.find((s) => s.type === provider.type);
|
||||
return {
|
||||
type: provider.type,
|
||||
displayName: provider.displayName,
|
||||
resourceTypes: provider.resourceTypes,
|
||||
status: service?.status || 'not-deployed',
|
||||
containerId: service?.containerId,
|
||||
createdAt: service?.createdAt,
|
||||
updatedAt: service?.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
return this.jsonResponse({ success: true, data: result });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list platform services: ${error.message}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: error.message || 'Failed to list platform services',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGetPlatformServiceRequest(type: string): Promise<Response> {
|
||||
try {
|
||||
const provider = this.oneboxRef.platformServices.getProvider(type);
|
||||
if (!provider) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: `Unknown platform service type: ${type}`,
|
||||
}, 404);
|
||||
}
|
||||
|
||||
const service = this.oneboxRef.database.getPlatformServiceByType(type);
|
||||
|
||||
// Get resource count
|
||||
const allResources = service?.id
|
||||
? this.oneboxRef.database.getPlatformResourcesByPlatformService(service.id)
|
||||
: [];
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
type: provider.type,
|
||||
displayName: provider.displayName,
|
||||
resourceTypes: provider.resourceTypes,
|
||||
status: service?.status || 'not-deployed',
|
||||
containerId: service?.containerId,
|
||||
config: provider.getDefaultConfig(),
|
||||
resourceCount: allResources.length,
|
||||
createdAt: service?.createdAt,
|
||||
updatedAt: service?.updatedAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get platform service ${type}: ${error.message}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: error.message || 'Failed to get platform service',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStartPlatformServiceRequest(type: string): Promise<Response> {
|
||||
try {
|
||||
const provider = this.oneboxRef.platformServices.getProvider(type);
|
||||
if (!provider) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: `Unknown platform service type: ${type}`,
|
||||
}, 404);
|
||||
}
|
||||
|
||||
logger.info(`Starting platform service: ${type}`);
|
||||
const service = await this.oneboxRef.platformServices.ensureRunning(type);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: `Platform service ${provider.displayName} started`,
|
||||
data: {
|
||||
type: service.type,
|
||||
status: service.status,
|
||||
containerId: service.containerId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start platform service ${type}: ${error.message}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: error.message || 'Failed to start platform service',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStopPlatformServiceRequest(type: string): Promise<Response> {
|
||||
try {
|
||||
const provider = this.oneboxRef.platformServices.getProvider(type);
|
||||
if (!provider) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: `Unknown platform service type: ${type}`,
|
||||
}, 404);
|
||||
}
|
||||
|
||||
logger.info(`Stopping platform service: ${type}`);
|
||||
await this.oneboxRef.platformServices.stopPlatformService(type);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: `Platform service ${provider.displayName} stopped`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop platform service ${type}: ${error.message}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: error.message || 'Failed to stop platform service',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGetServicePlatformResourcesRequest(serviceName: string): Promise<Response> {
|
||||
try {
|
||||
const service = this.oneboxRef.services.getService(serviceName);
|
||||
if (!service) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Service not found',
|
||||
}, 404);
|
||||
}
|
||||
|
||||
const resources = await this.oneboxRef.services.getServicePlatformResources(serviceName);
|
||||
|
||||
// Format resources for API response (mask sensitive credentials)
|
||||
const formattedResources = resources.map((r) => ({
|
||||
id: r.resource.id,
|
||||
resourceType: r.resource.resourceType,
|
||||
resourceName: r.resource.resourceName,
|
||||
platformService: {
|
||||
type: r.platformService.type,
|
||||
name: r.platformService.name,
|
||||
status: r.platformService.status,
|
||||
},
|
||||
// Include env var mappings (show keys, not values)
|
||||
envVars: Object.keys(r.credentials).reduce((acc, key) => {
|
||||
// Mask sensitive values
|
||||
const value = r.credentials[key];
|
||||
if (key.toLowerCase().includes('password') || key.toLowerCase().includes('secret')) {
|
||||
acc[key] = '********';
|
||||
} else {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>),
|
||||
createdAt: r.resource.createdAt,
|
||||
}));
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
data: formattedResources,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get platform resources for service ${serviceName}: ${error.message}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: error.message || 'Failed to get platform resources',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Registry Endpoints ============
|
||||
|
||||
private async handleGetRegistryTagsRequest(serviceName: string): Promise<Response> {
|
||||
@@ -1047,51 +1243,206 @@ export class OneboxHttpServer {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGetRegistryTokenRequest(serviceName: string): Promise<Response> {
|
||||
// ============ Registry Token Management Endpoints ============
|
||||
|
||||
private async handleListRegistryTokensRequest(req: Request): Promise<Response> {
|
||||
try {
|
||||
// Get the service to verify it exists
|
||||
const service = this.oneboxRef.database.getServiceByName(serviceName);
|
||||
if (!service) {
|
||||
const tokens = this.oneboxRef.database.getAllRegistryTokens();
|
||||
|
||||
// Convert to view format (mask token hash, add computed fields)
|
||||
const tokenViews: IRegistryTokenView[] = tokens.map(token => {
|
||||
const now = Date.now();
|
||||
const isExpired = token.expiresAt !== null && token.expiresAt < now;
|
||||
|
||||
// Generate scope display string
|
||||
let scopeDisplay: string;
|
||||
if (token.scope === 'all') {
|
||||
scopeDisplay = 'All services';
|
||||
} else if (Array.isArray(token.scope)) {
|
||||
scopeDisplay = token.scope.length === 1
|
||||
? token.scope[0]
|
||||
: `${token.scope.length} services`;
|
||||
} else {
|
||||
scopeDisplay = 'Unknown';
|
||||
}
|
||||
|
||||
return {
|
||||
id: token.id!,
|
||||
name: token.name,
|
||||
type: token.type,
|
||||
scope: token.scope,
|
||||
scopeDisplay,
|
||||
expiresAt: token.expiresAt,
|
||||
createdAt: token.createdAt,
|
||||
lastUsedAt: token.lastUsedAt,
|
||||
createdBy: token.createdBy,
|
||||
isExpired,
|
||||
};
|
||||
});
|
||||
|
||||
return this.jsonResponse({ success: true, data: tokenViews });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list registry tokens: ${error.message}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: error.message || 'Failed to list registry tokens',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCreateRegistryTokenRequest(req: Request): Promise<Response> {
|
||||
try {
|
||||
const body = await req.json() as ICreateRegistryTokenRequest;
|
||||
|
||||
// Validate request
|
||||
if (!body.name || !body.type || !body.scope || !body.expiresIn) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Service not found',
|
||||
}, 404);
|
||||
error: 'Missing required fields: name, type, scope, expiresIn',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// If service already has a token, return it
|
||||
if (service.registryToken) {
|
||||
if (body.type !== 'global' && body.type !== 'ci') {
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
token: service.registryToken,
|
||||
repository: serviceName,
|
||||
baseUrl: this.oneboxRef.registry.getBaseUrl(),
|
||||
},
|
||||
});
|
||||
success: false,
|
||||
error: 'Invalid token type. Must be "global" or "ci"',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Generate new token
|
||||
const token = await this.oneboxRef.registry.createServiceToken(serviceName);
|
||||
// Validate scope
|
||||
if (body.scope !== 'all' && !Array.isArray(body.scope)) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Scope must be "all" or an array of service names',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Save token to database
|
||||
this.oneboxRef.database.updateService(service.id!, {
|
||||
registryToken: token,
|
||||
registryRepository: serviceName,
|
||||
// If scope is array of services, validate they exist
|
||||
if (Array.isArray(body.scope)) {
|
||||
for (const serviceName of body.scope) {
|
||||
const service = this.oneboxRef.database.getServiceByName(serviceName);
|
||||
if (!service) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: `Service not found: ${serviceName}`,
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate expiration timestamp
|
||||
const now = Date.now();
|
||||
let expiresAt: number | null = null;
|
||||
if (body.expiresIn !== 'never') {
|
||||
const daysMap: Record<string, number> = {
|
||||
'30d': 30,
|
||||
'90d': 90,
|
||||
'365d': 365,
|
||||
};
|
||||
const days = daysMap[body.expiresIn];
|
||||
if (days) {
|
||||
expiresAt = now + (days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate token (random 32 bytes as hex)
|
||||
const plainToken = crypto.randomUUID() + crypto.randomUUID();
|
||||
|
||||
// Hash the token for storage (using simple hash for now)
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(plainToken);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const tokenHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
// Get username from auth token
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
let createdBy = 'system';
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
try {
|
||||
const decoded = atob(authHeader.slice(7));
|
||||
createdBy = decoded.split(':')[0];
|
||||
} catch {
|
||||
// Keep default
|
||||
}
|
||||
}
|
||||
|
||||
// Create token in database
|
||||
const token = this.oneboxRef.database.createRegistryToken({
|
||||
name: body.name,
|
||||
tokenHash,
|
||||
type: body.type,
|
||||
scope: body.scope,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
lastUsedAt: null,
|
||||
createdBy,
|
||||
});
|
||||
|
||||
// Build view response
|
||||
let scopeDisplay: string;
|
||||
if (token.scope === 'all') {
|
||||
scopeDisplay = 'All services';
|
||||
} else if (Array.isArray(token.scope)) {
|
||||
scopeDisplay = token.scope.length === 1
|
||||
? token.scope[0]
|
||||
: `${token.scope.length} services`;
|
||||
} else {
|
||||
scopeDisplay = 'Unknown';
|
||||
}
|
||||
|
||||
const tokenView: IRegistryTokenView = {
|
||||
id: token.id!,
|
||||
name: token.name,
|
||||
type: token.type,
|
||||
scope: token.scope,
|
||||
scopeDisplay,
|
||||
expiresAt: token.expiresAt,
|
||||
createdAt: token.createdAt,
|
||||
lastUsedAt: token.lastUsedAt,
|
||||
createdBy: token.createdBy,
|
||||
isExpired: false,
|
||||
};
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
token: token,
|
||||
repository: serviceName,
|
||||
baseUrl: this.oneboxRef.registry.getBaseUrl(),
|
||||
token: tokenView,
|
||||
plainToken, // Only returned once at creation
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get registry token for ${serviceName}: ${error.message}`);
|
||||
logger.error(`Failed to create registry token: ${error.message}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: error.message || 'Failed to get registry token',
|
||||
error: error.message || 'Failed to create registry token',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDeleteRegistryTokenRequest(tokenId: number): Promise<Response> {
|
||||
try {
|
||||
// Check if token exists
|
||||
const token = this.oneboxRef.database.getRegistryTokenById(tokenId);
|
||||
if (!token) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Token not found',
|
||||
}, 404);
|
||||
}
|
||||
|
||||
// Delete the token
|
||||
this.oneboxRef.database.deleteRegistryToken(tokenId);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: 'Token deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete registry token ${tokenId}: ${error.message}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: error.message || 'Failed to delete registry token',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { OneboxHttpServer } from './httpserver.ts';
|
||||
import { CloudflareDomainSync } from './cloudflare-sync.ts';
|
||||
import { CertRequirementManager } from './cert-requirement-manager.ts';
|
||||
import { RegistryManager } from './registry.ts';
|
||||
import { PlatformServicesManager } from './platform-services/index.ts';
|
||||
|
||||
export class Onebox {
|
||||
public database: OneboxDatabase;
|
||||
@@ -31,6 +32,7 @@ export class Onebox {
|
||||
public cloudflareDomainSync: CloudflareDomainSync;
|
||||
public certRequirementManager: CertRequirementManager;
|
||||
public registry: RegistryManager;
|
||||
public platformServices: PlatformServicesManager;
|
||||
|
||||
private initialized = false;
|
||||
|
||||
@@ -56,6 +58,9 @@ export class Onebox {
|
||||
// Initialize domain management
|
||||
this.cloudflareDomainSync = new CloudflareDomainSync(this.database);
|
||||
this.certRequirementManager = new CertRequirementManager(this.database, this.ssl);
|
||||
|
||||
// Initialize platform services manager
|
||||
this.platformServices = new PlatformServicesManager(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,6 +111,14 @@ export class Onebox {
|
||||
logger.warn(`Error: ${error.message}`);
|
||||
}
|
||||
|
||||
// Initialize Platform Services (non-critical)
|
||||
try {
|
||||
await this.platformServices.init();
|
||||
} catch (error) {
|
||||
logger.warn('Platform services initialization failed - MongoDB/S3 features will be limited');
|
||||
logger.warn(`Error: ${error.message}`);
|
||||
}
|
||||
|
||||
// Login to all registries
|
||||
await this.registries.loginToAllRegistries();
|
||||
|
||||
@@ -170,6 +183,13 @@ export class Onebox {
|
||||
const runningServices = services.filter((s) => s.status === 'running').length;
|
||||
const totalServices = services.length;
|
||||
|
||||
// Get platform services status
|
||||
const platformServices = this.platformServices.getAllPlatformServices();
|
||||
const platformServicesStatus = platformServices.map((ps) => ({
|
||||
type: ps.type,
|
||||
status: ps.status,
|
||||
}));
|
||||
|
||||
return {
|
||||
docker: {
|
||||
running: dockerRunning,
|
||||
@@ -188,6 +208,7 @@ export class Onebox {
|
||||
running: runningServices,
|
||||
stopped: totalServices - runningServices,
|
||||
},
|
||||
platformServices: platformServicesStatus,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get system status: ${error.message}`);
|
||||
|
||||
10
ts/classes/platform-services/index.ts
Normal file
10
ts/classes/platform-services/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Platform Services Module
|
||||
* Exports all platform service related classes and types
|
||||
*/
|
||||
|
||||
export { PlatformServicesManager } from './manager.ts';
|
||||
export type { IPlatformServiceProvider } from './providers/base.ts';
|
||||
export { BasePlatformServiceProvider } from './providers/base.ts';
|
||||
export { MongoDBProvider } from './providers/mongodb.ts';
|
||||
export { MinioProvider } from './providers/minio.ts';
|
||||
361
ts/classes/platform-services/manager.ts
Normal file
361
ts/classes/platform-services/manager.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Platform Services Manager
|
||||
* Orchestrates platform services (MongoDB, MinIO) and their resources
|
||||
*/
|
||||
|
||||
import type {
|
||||
IService,
|
||||
IPlatformService,
|
||||
IPlatformResource,
|
||||
IPlatformRequirements,
|
||||
IProvisionedResource,
|
||||
TPlatformServiceType,
|
||||
} from '../../types.ts';
|
||||
import type { IPlatformServiceProvider } from './providers/base.ts';
|
||||
import { MongoDBProvider } from './providers/mongodb.ts';
|
||||
import { MinioProvider } from './providers/minio.ts';
|
||||
import { logger } from '../../logging.ts';
|
||||
import { credentialEncryption } from '../encryption.ts';
|
||||
import type { Onebox } from '../onebox.ts';
|
||||
|
||||
export class PlatformServicesManager {
|
||||
private oneboxRef: Onebox;
|
||||
private providers = new Map<TPlatformServiceType, IPlatformServiceProvider>();
|
||||
|
||||
constructor(oneboxRef: Onebox) {
|
||||
this.oneboxRef = oneboxRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the platform services manager
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
// Initialize encryption
|
||||
await credentialEncryption.init();
|
||||
|
||||
// Register providers
|
||||
this.registerProvider(new MongoDBProvider(this.oneboxRef));
|
||||
this.registerProvider(new MinioProvider(this.oneboxRef));
|
||||
|
||||
logger.info(`Platform services manager initialized with ${this.providers.size} providers`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a platform service provider
|
||||
*/
|
||||
registerProvider(provider: IPlatformServiceProvider): void {
|
||||
this.providers.set(provider.type, provider);
|
||||
logger.debug(`Registered platform service provider: ${provider.displayName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a provider by type
|
||||
*/
|
||||
getProvider(type: TPlatformServiceType): IPlatformServiceProvider | undefined {
|
||||
return this.providers.get(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered providers
|
||||
*/
|
||||
getAllProviders(): IPlatformServiceProvider[] {
|
||||
return Array.from(this.providers.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a platform service is running, deploying it if necessary
|
||||
*/
|
||||
async ensureRunning(type: TPlatformServiceType): Promise<IPlatformService> {
|
||||
const provider = this.providers.get(type);
|
||||
if (!provider) {
|
||||
throw new Error(`Unknown platform service type: ${type}`);
|
||||
}
|
||||
|
||||
// Check if platform service exists in database
|
||||
let platformService = this.oneboxRef.database.getPlatformServiceByType(type);
|
||||
|
||||
if (!platformService) {
|
||||
// Create platform service record
|
||||
logger.info(`Creating new ${provider.displayName} platform service...`);
|
||||
const config = provider.getDefaultConfig();
|
||||
|
||||
platformService = this.oneboxRef.database.createPlatformService({
|
||||
name: `onebox-${type}`,
|
||||
type,
|
||||
status: 'stopped',
|
||||
config,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already running
|
||||
if (platformService.status === 'running') {
|
||||
// Verify it's actually healthy
|
||||
const isHealthy = await provider.healthCheck();
|
||||
if (isHealthy) {
|
||||
logger.debug(`${provider.displayName} is already running and healthy`);
|
||||
return platformService;
|
||||
}
|
||||
logger.warn(`${provider.displayName} reports running but health check failed, restarting...`);
|
||||
}
|
||||
|
||||
// Deploy if not running
|
||||
if (platformService.status !== 'running') {
|
||||
logger.info(`Starting ${provider.displayName} platform service...`);
|
||||
|
||||
try {
|
||||
this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'starting' });
|
||||
|
||||
const containerId = await provider.deployContainer();
|
||||
|
||||
// Wait for health check to pass
|
||||
const healthy = await this.waitForHealthy(type, 60000); // 60 second timeout
|
||||
|
||||
if (healthy) {
|
||||
this.oneboxRef.database.updatePlatformService(platformService.id!, {
|
||||
status: 'running',
|
||||
containerId,
|
||||
});
|
||||
logger.success(`${provider.displayName} platform service is now running`);
|
||||
} else {
|
||||
this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'failed' });
|
||||
throw new Error(`${provider.displayName} failed to start within timeout`);
|
||||
}
|
||||
|
||||
// Refresh platform service from database
|
||||
platformService = this.oneboxRef.database.getPlatformServiceByType(type)!;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start ${provider.displayName}: ${error.message}`);
|
||||
this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'failed' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return platformService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a platform service to become healthy
|
||||
*/
|
||||
private async waitForHealthy(type: TPlatformServiceType, timeoutMs: number): Promise<boolean> {
|
||||
const provider = this.providers.get(type);
|
||||
if (!provider) return false;
|
||||
|
||||
const startTime = Date.now();
|
||||
const checkInterval = 2000; // Check every 2 seconds
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const isHealthy = await provider.healthCheck();
|
||||
if (isHealthy) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a platform service
|
||||
*/
|
||||
async stopPlatformService(type: TPlatformServiceType): Promise<void> {
|
||||
const provider = this.providers.get(type);
|
||||
if (!provider) {
|
||||
throw new Error(`Unknown platform service type: ${type}`);
|
||||
}
|
||||
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(type);
|
||||
if (!platformService) {
|
||||
logger.warn(`Platform service ${type} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!platformService.containerId) {
|
||||
logger.warn(`Platform service ${type} has no container ID`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Stopping ${provider.displayName} platform service...`);
|
||||
this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'stopping' });
|
||||
|
||||
try {
|
||||
await provider.stopContainer(platformService.containerId);
|
||||
this.oneboxRef.database.updatePlatformService(platformService.id!, {
|
||||
status: 'stopped',
|
||||
containerId: undefined,
|
||||
});
|
||||
logger.success(`${provider.displayName} platform service stopped`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop ${provider.displayName}: ${error.message}`);
|
||||
this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'failed' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision platform resources for a user service based on its requirements
|
||||
*/
|
||||
async provisionForService(service: IService): Promise<Record<string, string>> {
|
||||
const requirements = service.platformRequirements;
|
||||
if (!requirements) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const allEnvVars: Record<string, string> = {};
|
||||
|
||||
// Provision MongoDB if requested
|
||||
if (requirements.mongodb) {
|
||||
logger.info(`Provisioning MongoDB for service '${service.name}'...`);
|
||||
|
||||
// Ensure MongoDB is running
|
||||
const mongoService = await this.ensureRunning('mongodb');
|
||||
const provider = this.providers.get('mongodb')!;
|
||||
|
||||
// Provision database
|
||||
const result = await provider.provisionResource(service);
|
||||
|
||||
// Store resource record
|
||||
const encryptedCreds = await credentialEncryption.encrypt(result.credentials);
|
||||
this.oneboxRef.database.createPlatformResource({
|
||||
platformServiceId: mongoService.id!,
|
||||
serviceId: service.id!,
|
||||
resourceType: result.type,
|
||||
resourceName: result.name,
|
||||
credentialsEncrypted: encryptedCreds,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
// Merge env vars
|
||||
Object.assign(allEnvVars, result.envVars);
|
||||
logger.success(`MongoDB provisioned for service '${service.name}'`);
|
||||
}
|
||||
|
||||
// Provision S3/MinIO if requested
|
||||
if (requirements.s3) {
|
||||
logger.info(`Provisioning S3 storage for service '${service.name}'...`);
|
||||
|
||||
// Ensure MinIO is running
|
||||
const minioService = await this.ensureRunning('minio');
|
||||
const provider = this.providers.get('minio')!;
|
||||
|
||||
// Provision bucket
|
||||
const result = await provider.provisionResource(service);
|
||||
|
||||
// Store resource record
|
||||
const encryptedCreds = await credentialEncryption.encrypt(result.credentials);
|
||||
this.oneboxRef.database.createPlatformResource({
|
||||
platformServiceId: minioService.id!,
|
||||
serviceId: service.id!,
|
||||
resourceType: result.type,
|
||||
resourceName: result.name,
|
||||
credentialsEncrypted: encryptedCreds,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
// Merge env vars
|
||||
Object.assign(allEnvVars, result.envVars);
|
||||
logger.success(`S3 storage provisioned for service '${service.name}'`);
|
||||
}
|
||||
|
||||
return allEnvVars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup platform resources when a user service is deleted
|
||||
*/
|
||||
async cleanupForService(serviceId: number): Promise<void> {
|
||||
const resources = this.oneboxRef.database.getPlatformResourcesByService(serviceId);
|
||||
|
||||
for (const resource of resources) {
|
||||
try {
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceById(resource.platformServiceId);
|
||||
if (!platformService) {
|
||||
logger.warn(`Platform service not found for resource ${resource.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const provider = this.providers.get(platformService.type);
|
||||
if (!provider) {
|
||||
logger.warn(`Provider not found for type ${platformService.type}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decrypt credentials
|
||||
const credentials = await credentialEncryption.decrypt(resource.credentialsEncrypted);
|
||||
|
||||
// Deprovision the resource
|
||||
logger.info(`Cleaning up ${resource.resourceType} '${resource.resourceName}'...`);
|
||||
await provider.deprovisionResource(resource, credentials);
|
||||
|
||||
// Delete resource record
|
||||
this.oneboxRef.database.deletePlatformResource(resource.id!);
|
||||
logger.success(`Cleaned up ${resource.resourceType} '${resource.resourceName}'`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to cleanup resource ${resource.id}: ${error.message}`);
|
||||
// Continue with other resources even if one fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get injected environment variables for a service
|
||||
*/
|
||||
async getInjectedEnvVars(serviceId: number): Promise<Record<string, string>> {
|
||||
const resources = this.oneboxRef.database.getPlatformResourcesByService(serviceId);
|
||||
const allEnvVars: Record<string, string> = {};
|
||||
|
||||
for (const resource of resources) {
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceById(resource.platformServiceId);
|
||||
if (!platformService) continue;
|
||||
|
||||
const provider = this.providers.get(platformService.type);
|
||||
if (!provider) continue;
|
||||
|
||||
const credentials = await credentialEncryption.decrypt(resource.credentialsEncrypted);
|
||||
const mappings = provider.getEnvVarMappings();
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (credentials[mapping.credentialPath]) {
|
||||
allEnvVars[mapping.envVar] = credentials[mapping.credentialPath];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allEnvVars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all platform services with their status
|
||||
*/
|
||||
getAllPlatformServices(): IPlatformService[] {
|
||||
return this.oneboxRef.database.getAllPlatformServices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resources for a specific user service
|
||||
*/
|
||||
async getResourcesForService(serviceId: number): Promise<Array<{
|
||||
resource: IPlatformResource;
|
||||
platformService: IPlatformService;
|
||||
credentials: Record<string, string>;
|
||||
}>> {
|
||||
const resources = this.oneboxRef.database.getPlatformResourcesByService(serviceId);
|
||||
const result = [];
|
||||
|
||||
for (const resource of resources) {
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceById(resource.platformServiceId);
|
||||
if (!platformService) continue;
|
||||
|
||||
const credentials = await credentialEncryption.decrypt(resource.credentialsEncrypted);
|
||||
|
||||
result.push({
|
||||
resource,
|
||||
platformService,
|
||||
credentials,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
123
ts/classes/platform-services/providers/base.ts
Normal file
123
ts/classes/platform-services/providers/base.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Base interface and types for platform service providers
|
||||
*/
|
||||
|
||||
import type {
|
||||
IService,
|
||||
IPlatformService,
|
||||
IPlatformResource,
|
||||
IPlatformServiceConfig,
|
||||
IProvisionedResource,
|
||||
IEnvVarMapping,
|
||||
TPlatformServiceType,
|
||||
TPlatformResourceType,
|
||||
} from '../../../types.ts';
|
||||
import type { Onebox } from '../../onebox.ts';
|
||||
|
||||
/**
|
||||
* Interface that all platform service providers must implement
|
||||
*/
|
||||
export interface IPlatformServiceProvider {
|
||||
/** Unique identifier for this provider type */
|
||||
readonly type: TPlatformServiceType;
|
||||
|
||||
/** Human-readable display name */
|
||||
readonly displayName: string;
|
||||
|
||||
/** Resource types this provider can provision */
|
||||
readonly resourceTypes: TPlatformResourceType[];
|
||||
|
||||
/**
|
||||
* Get the default configuration for this platform service
|
||||
*/
|
||||
getDefaultConfig(): IPlatformServiceConfig;
|
||||
|
||||
/**
|
||||
* Deploy the platform service container
|
||||
* @returns The container ID
|
||||
*/
|
||||
deployContainer(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Stop the platform service container
|
||||
*/
|
||||
stopContainer(containerId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if the platform service is healthy and ready to accept connections
|
||||
*/
|
||||
healthCheck(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Provision a resource for a user service (e.g., create database, bucket)
|
||||
* @param userService The user service requesting the resource
|
||||
* @returns Provisioned resource with credentials and env var mappings
|
||||
*/
|
||||
provisionResource(userService: IService): Promise<IProvisionedResource>;
|
||||
|
||||
/**
|
||||
* Deprovision a resource (e.g., drop database, delete bucket)
|
||||
* @param resource The resource to deprovision
|
||||
*/
|
||||
deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the environment variable mappings for this provider
|
||||
*/
|
||||
getEnvVarMappings(): IEnvVarMapping[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for platform service providers with common functionality
|
||||
*/
|
||||
export abstract class BasePlatformServiceProvider implements IPlatformServiceProvider {
|
||||
abstract readonly type: TPlatformServiceType;
|
||||
abstract readonly displayName: string;
|
||||
abstract readonly resourceTypes: TPlatformResourceType[];
|
||||
|
||||
protected oneboxRef: Onebox;
|
||||
|
||||
constructor(oneboxRef: Onebox) {
|
||||
this.oneboxRef = oneboxRef;
|
||||
}
|
||||
|
||||
abstract getDefaultConfig(): IPlatformServiceConfig;
|
||||
abstract deployContainer(): Promise<string>;
|
||||
abstract stopContainer(containerId: string): Promise<void>;
|
||||
abstract healthCheck(): Promise<boolean>;
|
||||
abstract provisionResource(userService: IService): Promise<IProvisionedResource>;
|
||||
abstract deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void>;
|
||||
abstract getEnvVarMappings(): IEnvVarMapping[];
|
||||
|
||||
/**
|
||||
* Get the internal Docker network name for platform services
|
||||
*/
|
||||
protected getNetworkName(): string {
|
||||
return 'onebox-network';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the container name for this platform service
|
||||
*/
|
||||
protected getContainerName(): string {
|
||||
return `onebox-${this.type}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a resource name from a user service name
|
||||
*/
|
||||
protected generateResourceName(serviceName: string, prefix: string = 'onebox'): string {
|
||||
// Replace dashes with underscores for database compatibility
|
||||
const sanitized = serviceName.replace(/-/g, '_');
|
||||
return `${prefix}_${sanitized}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a bucket name from a user service name
|
||||
*/
|
||||
protected generateBucketName(serviceName: string, prefix: string = 'onebox'): string {
|
||||
// Buckets use dashes, lowercase
|
||||
const sanitized = serviceName.toLowerCase().replace(/_/g, '-');
|
||||
return `${prefix}-${sanitized}`;
|
||||
}
|
||||
}
|
||||
299
ts/classes/platform-services/providers/minio.ts
Normal file
299
ts/classes/platform-services/providers/minio.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* MinIO (S3-compatible) Platform Service Provider
|
||||
*/
|
||||
|
||||
import { BasePlatformServiceProvider } from './base.ts';
|
||||
import type {
|
||||
IService,
|
||||
IPlatformResource,
|
||||
IPlatformServiceConfig,
|
||||
IProvisionedResource,
|
||||
IEnvVarMapping,
|
||||
TPlatformServiceType,
|
||||
TPlatformResourceType,
|
||||
} from '../../../types.ts';
|
||||
import { logger } from '../../../logging.ts';
|
||||
import { credentialEncryption } from '../../encryption.ts';
|
||||
import type { Onebox } from '../../onebox.ts';
|
||||
|
||||
export class MinioProvider extends BasePlatformServiceProvider {
|
||||
readonly type: TPlatformServiceType = 'minio';
|
||||
readonly displayName = 'S3 Storage (MinIO)';
|
||||
readonly resourceTypes: TPlatformResourceType[] = ['bucket'];
|
||||
|
||||
constructor(oneboxRef: Onebox) {
|
||||
super(oneboxRef);
|
||||
}
|
||||
|
||||
getDefaultConfig(): IPlatformServiceConfig {
|
||||
return {
|
||||
image: 'minio/minio:latest',
|
||||
port: 9000,
|
||||
volumes: ['/var/lib/onebox/minio:/data'],
|
||||
command: 'server /data --console-address :9001',
|
||||
environment: {
|
||||
MINIO_ROOT_USER: 'admin',
|
||||
// Password will be generated and stored encrypted
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getEnvVarMappings(): IEnvVarMapping[] {
|
||||
return [
|
||||
{ envVar: 'S3_ENDPOINT', credentialPath: 'endpoint' },
|
||||
{ envVar: 'S3_BUCKET', credentialPath: 'bucket' },
|
||||
{ envVar: 'S3_ACCESS_KEY', credentialPath: 'accessKey' },
|
||||
{ envVar: 'S3_SECRET_KEY', credentialPath: 'secretKey' },
|
||||
{ envVar: 'S3_REGION', credentialPath: 'region' },
|
||||
// AWS SDK compatible names
|
||||
{ envVar: 'AWS_ACCESS_KEY_ID', credentialPath: 'accessKey' },
|
||||
{ envVar: 'AWS_SECRET_ACCESS_KEY', credentialPath: 'secretKey' },
|
||||
{ envVar: 'AWS_ENDPOINT_URL', credentialPath: 'endpoint' },
|
||||
{ envVar: 'AWS_REGION', credentialPath: 'region' },
|
||||
];
|
||||
}
|
||||
|
||||
async deployContainer(): Promise<string> {
|
||||
const config = this.getDefaultConfig();
|
||||
const containerName = this.getContainerName();
|
||||
|
||||
// Generate admin credentials
|
||||
const adminUser = 'admin';
|
||||
const adminPassword = credentialEncryption.generatePassword(32);
|
||||
|
||||
const adminCredentials = {
|
||||
username: adminUser,
|
||||
password: adminPassword,
|
||||
};
|
||||
|
||||
logger.info(`Deploying MinIO platform service as ${containerName}...`);
|
||||
|
||||
// Ensure data directory exists
|
||||
try {
|
||||
await Deno.mkdir('/var/lib/onebox/minio', { recursive: true });
|
||||
} catch (e) {
|
||||
if (!(e instanceof Deno.errors.AlreadyExists)) {
|
||||
logger.warn(`Could not create MinIO data directory: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create container using Docker API
|
||||
const envVars = [
|
||||
`MINIO_ROOT_USER=${adminCredentials.username}`,
|
||||
`MINIO_ROOT_PASSWORD=${adminCredentials.password}`,
|
||||
];
|
||||
|
||||
const containerId = await this.oneboxRef.docker.createPlatformContainer({
|
||||
name: containerName,
|
||||
image: config.image,
|
||||
port: config.port,
|
||||
env: envVars,
|
||||
volumes: config.volumes,
|
||||
network: this.getNetworkName(),
|
||||
command: config.command?.split(' '),
|
||||
exposePorts: [9000, 9001], // API and Console ports
|
||||
});
|
||||
|
||||
// Store encrypted admin credentials
|
||||
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||
if (platformService) {
|
||||
this.oneboxRef.database.updatePlatformService(platformService.id!, {
|
||||
containerId,
|
||||
adminCredentialsEncrypted: encryptedCreds,
|
||||
status: 'starting',
|
||||
});
|
||||
}
|
||||
|
||||
logger.success(`MinIO container created: ${containerId}`);
|
||||
return containerId;
|
||||
}
|
||||
|
||||
async stopContainer(containerId: string): Promise<void> {
|
||||
logger.info(`Stopping MinIO container ${containerId}...`);
|
||||
await this.oneboxRef.docker.stopContainer(containerId);
|
||||
logger.success('MinIO container stopped');
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const containerName = this.getContainerName();
|
||||
const endpoint = `http://${containerName}:9000/minio/health/live`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
logger.debug(`MinIO health check failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async provisionResource(userService: IService): Promise<IProvisionedResource> {
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||
if (!platformService || !platformService.adminCredentialsEncrypted) {
|
||||
throw new Error('MinIO platform service not found or not configured');
|
||||
}
|
||||
|
||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
const containerName = this.getContainerName();
|
||||
|
||||
// Generate bucket name and credentials
|
||||
const bucketName = this.generateBucketName(userService.name);
|
||||
const accessKey = credentialEncryption.generateAccessKey(20);
|
||||
const secretKey = credentialEncryption.generateSecretKey(40);
|
||||
|
||||
logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`);
|
||||
|
||||
const endpoint = `http://${containerName}:9000`;
|
||||
|
||||
// Import AWS S3 client
|
||||
const { S3Client, CreateBucketCommand, PutBucketPolicyCommand } = await import('npm:@aws-sdk/client-s3@3');
|
||||
|
||||
// Create S3 client with admin credentials
|
||||
const s3Client = new S3Client({
|
||||
endpoint,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: adminCreds.username,
|
||||
secretAccessKey: adminCreds.password,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
// Create the bucket
|
||||
try {
|
||||
await s3Client.send(new CreateBucketCommand({
|
||||
Bucket: bucketName,
|
||||
}));
|
||||
logger.info(`Created MinIO bucket '${bucketName}'`);
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'BucketAlreadyOwnedByYou' && e.name !== 'BucketAlreadyExists') {
|
||||
throw e;
|
||||
}
|
||||
logger.warn(`Bucket '${bucketName}' already exists`);
|
||||
}
|
||||
|
||||
// Create service account/access key using MinIO Admin API
|
||||
// MinIO Admin API requires mc client or direct API calls
|
||||
// For simplicity, we'll use root credentials and bucket policy isolation
|
||||
// In production, you'd use MinIO's Admin API to create service accounts
|
||||
|
||||
// Set bucket policy to allow access only with this bucket's credentials
|
||||
const bucketPolicy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: ['*'] },
|
||||
Action: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject', 's3:ListBucket'],
|
||||
Resource: [
|
||||
`arn:aws:s3:::${bucketName}`,
|
||||
`arn:aws:s3:::${bucketName}/*`,
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
await s3Client.send(new PutBucketPolicyCommand({
|
||||
Bucket: bucketName,
|
||||
Policy: JSON.stringify(bucketPolicy),
|
||||
}));
|
||||
logger.info(`Set bucket policy for '${bucketName}'`);
|
||||
} catch (e) {
|
||||
logger.warn(`Could not set bucket policy: ${e.message}`);
|
||||
}
|
||||
|
||||
// Note: For proper per-service credentials, MinIO Admin API should be used
|
||||
// For now, we're providing the bucket with root access
|
||||
// TODO: Implement MinIO service account creation
|
||||
logger.warn('Using root credentials for MinIO access. Consider implementing service accounts for production.');
|
||||
|
||||
const credentials: Record<string, string> = {
|
||||
endpoint,
|
||||
bucket: bucketName,
|
||||
accessKey: adminCreds.username, // Using root for now
|
||||
secretKey: adminCreds.password,
|
||||
region: 'us-east-1',
|
||||
};
|
||||
|
||||
// Map credentials to env vars
|
||||
const envVars: Record<string, string> = {};
|
||||
for (const mapping of this.getEnvVarMappings()) {
|
||||
if (credentials[mapping.credentialPath]) {
|
||||
envVars[mapping.envVar] = credentials[mapping.credentialPath];
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`MinIO bucket '${bucketName}' provisioned`);
|
||||
|
||||
return {
|
||||
type: 'bucket',
|
||||
name: bucketName,
|
||||
credentials,
|
||||
envVars,
|
||||
};
|
||||
}
|
||||
|
||||
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||
if (!platformService || !platformService.adminCredentialsEncrypted) {
|
||||
throw new Error('MinIO platform service not found or not configured');
|
||||
}
|
||||
|
||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
const containerName = this.getContainerName();
|
||||
const endpoint = `http://${containerName}:9000`;
|
||||
|
||||
logger.info(`Deprovisioning MinIO bucket '${resource.resourceName}'...`);
|
||||
|
||||
const { S3Client, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = await import('npm:@aws-sdk/client-s3@3');
|
||||
|
||||
const s3Client = new S3Client({
|
||||
endpoint,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: adminCreds.username,
|
||||
secretAccessKey: adminCreds.password,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// First, delete all objects in the bucket
|
||||
let continuationToken: string | undefined;
|
||||
do {
|
||||
const listResponse = await s3Client.send(new ListObjectsV2Command({
|
||||
Bucket: resource.resourceName,
|
||||
ContinuationToken: continuationToken,
|
||||
}));
|
||||
|
||||
if (listResponse.Contents && listResponse.Contents.length > 0) {
|
||||
await s3Client.send(new DeleteObjectsCommand({
|
||||
Bucket: resource.resourceName,
|
||||
Delete: {
|
||||
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key! })),
|
||||
},
|
||||
}));
|
||||
logger.info(`Deleted ${listResponse.Contents.length} objects from bucket`);
|
||||
}
|
||||
|
||||
continuationToken = listResponse.IsTruncated ? listResponse.NextContinuationToken : undefined;
|
||||
} while (continuationToken);
|
||||
|
||||
// Now delete the bucket
|
||||
await s3Client.send(new DeleteBucketCommand({
|
||||
Bucket: resource.resourceName,
|
||||
}));
|
||||
|
||||
logger.success(`MinIO bucket '${resource.resourceName}' deleted`);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to delete MinIO bucket: ${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
246
ts/classes/platform-services/providers/mongodb.ts
Normal file
246
ts/classes/platform-services/providers/mongodb.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* MongoDB Platform Service Provider
|
||||
*/
|
||||
|
||||
import { BasePlatformServiceProvider } from './base.ts';
|
||||
import type {
|
||||
IService,
|
||||
IPlatformResource,
|
||||
IPlatformServiceConfig,
|
||||
IProvisionedResource,
|
||||
IEnvVarMapping,
|
||||
TPlatformServiceType,
|
||||
TPlatformResourceType,
|
||||
} from '../../../types.ts';
|
||||
import { logger } from '../../../logging.ts';
|
||||
import { credentialEncryption } from '../../encryption.ts';
|
||||
import type { Onebox } from '../../onebox.ts';
|
||||
|
||||
export class MongoDBProvider extends BasePlatformServiceProvider {
|
||||
readonly type: TPlatformServiceType = 'mongodb';
|
||||
readonly displayName = 'MongoDB';
|
||||
readonly resourceTypes: TPlatformResourceType[] = ['database'];
|
||||
|
||||
constructor(oneboxRef: Onebox) {
|
||||
super(oneboxRef);
|
||||
}
|
||||
|
||||
getDefaultConfig(): IPlatformServiceConfig {
|
||||
return {
|
||||
image: 'mongo:7',
|
||||
port: 27017,
|
||||
volumes: ['/var/lib/onebox/mongodb:/data/db'],
|
||||
environment: {
|
||||
MONGO_INITDB_ROOT_USERNAME: 'admin',
|
||||
// Password will be generated and stored encrypted
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getEnvVarMappings(): IEnvVarMapping[] {
|
||||
return [
|
||||
{ envVar: 'MONGODB_URI', credentialPath: 'connectionString' },
|
||||
{ envVar: 'MONGODB_HOST', credentialPath: 'host' },
|
||||
{ envVar: 'MONGODB_PORT', credentialPath: 'port' },
|
||||
{ envVar: 'MONGODB_DATABASE', credentialPath: 'database' },
|
||||
{ envVar: 'MONGODB_USERNAME', credentialPath: 'username' },
|
||||
{ envVar: 'MONGODB_PASSWORD', credentialPath: 'password' },
|
||||
];
|
||||
}
|
||||
|
||||
async deployContainer(): Promise<string> {
|
||||
const config = this.getDefaultConfig();
|
||||
const containerName = this.getContainerName();
|
||||
|
||||
// Generate admin password
|
||||
const adminPassword = credentialEncryption.generatePassword(32);
|
||||
|
||||
// Store admin credentials encrypted in the platform service record
|
||||
const adminCredentials = {
|
||||
username: 'admin',
|
||||
password: adminPassword,
|
||||
};
|
||||
|
||||
logger.info(`Deploying MongoDB platform service as ${containerName}...`);
|
||||
|
||||
// Ensure data directory exists
|
||||
try {
|
||||
await Deno.mkdir('/var/lib/onebox/mongodb', { recursive: true });
|
||||
} catch (e) {
|
||||
// Directory might already exist
|
||||
if (!(e instanceof Deno.errors.AlreadyExists)) {
|
||||
logger.warn(`Could not create MongoDB data directory: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create container using Docker API
|
||||
const envVars = [
|
||||
`MONGO_INITDB_ROOT_USERNAME=${adminCredentials.username}`,
|
||||
`MONGO_INITDB_ROOT_PASSWORD=${adminCredentials.password}`,
|
||||
];
|
||||
|
||||
// Use Docker to create the container
|
||||
const containerId = await this.oneboxRef.docker.createPlatformContainer({
|
||||
name: containerName,
|
||||
image: config.image,
|
||||
port: config.port,
|
||||
env: envVars,
|
||||
volumes: config.volumes,
|
||||
network: this.getNetworkName(),
|
||||
});
|
||||
|
||||
// Store encrypted admin credentials
|
||||
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||
if (platformService) {
|
||||
this.oneboxRef.database.updatePlatformService(platformService.id!, {
|
||||
containerId,
|
||||
adminCredentialsEncrypted: encryptedCreds,
|
||||
status: 'starting',
|
||||
});
|
||||
}
|
||||
|
||||
logger.success(`MongoDB container created: ${containerId}`);
|
||||
return containerId;
|
||||
}
|
||||
|
||||
async stopContainer(containerId: string): Promise<void> {
|
||||
logger.info(`Stopping MongoDB container ${containerId}...`);
|
||||
await this.oneboxRef.docker.stopContainer(containerId);
|
||||
logger.success('MongoDB container stopped');
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||
if (!platformService || !platformService.adminCredentialsEncrypted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
const containerName = this.getContainerName();
|
||||
|
||||
// Try to connect to MongoDB using mongosh ping
|
||||
const { MongoClient } = await import('npm:mongodb@6');
|
||||
const uri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`;
|
||||
|
||||
const client = new MongoClient(uri, {
|
||||
serverSelectionTimeoutMS: 5000,
|
||||
connectTimeoutMS: 5000,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
await client.db('admin').command({ ping: 1 });
|
||||
await client.close();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.debug(`MongoDB health check failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async provisionResource(userService: IService): Promise<IProvisionedResource> {
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||
if (!platformService || !platformService.adminCredentialsEncrypted) {
|
||||
throw new Error('MongoDB platform service not found or not configured');
|
||||
}
|
||||
|
||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
const containerName = this.getContainerName();
|
||||
|
||||
// Generate resource names and credentials
|
||||
const dbName = this.generateResourceName(userService.name);
|
||||
const username = this.generateResourceName(userService.name);
|
||||
const password = credentialEncryption.generatePassword(32);
|
||||
|
||||
logger.info(`Provisioning MongoDB database '${dbName}' for service '${userService.name}'...`);
|
||||
|
||||
// Connect to MongoDB and create database/user
|
||||
const { MongoClient } = await import('npm:mongodb@6');
|
||||
const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`;
|
||||
|
||||
const client = new MongoClient(adminUri);
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
// Create the database by switching to it (MongoDB creates on first write)
|
||||
const db = client.db(dbName);
|
||||
|
||||
// Create a collection to ensure the database exists
|
||||
await db.createCollection('_onebox_init');
|
||||
|
||||
// Create user with readWrite access to this database
|
||||
await db.command({
|
||||
createUser: username,
|
||||
pwd: password,
|
||||
roles: [{ role: 'readWrite', db: dbName }],
|
||||
});
|
||||
|
||||
logger.success(`MongoDB database '${dbName}' provisioned with user '${username}'`);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
|
||||
// Build the credentials and env vars
|
||||
const credentials: Record<string, string> = {
|
||||
host: containerName,
|
||||
port: '27017',
|
||||
database: dbName,
|
||||
username,
|
||||
password,
|
||||
connectionString: `mongodb://${username}:${password}@${containerName}:27017/${dbName}?authSource=${dbName}`,
|
||||
};
|
||||
|
||||
// Map credentials to env vars
|
||||
const envVars: Record<string, string> = {};
|
||||
for (const mapping of this.getEnvVarMappings()) {
|
||||
if (credentials[mapping.credentialPath]) {
|
||||
envVars[mapping.envVar] = credentials[mapping.credentialPath];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'database',
|
||||
name: dbName,
|
||||
credentials,
|
||||
envVars,
|
||||
};
|
||||
}
|
||||
|
||||
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||
if (!platformService || !platformService.adminCredentialsEncrypted) {
|
||||
throw new Error('MongoDB platform service not found or not configured');
|
||||
}
|
||||
|
||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
const containerName = this.getContainerName();
|
||||
|
||||
logger.info(`Deprovisioning MongoDB database '${resource.resourceName}'...`);
|
||||
|
||||
const { MongoClient } = await import('npm:mongodb@6');
|
||||
const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`;
|
||||
|
||||
const client = new MongoClient(adminUri);
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
const db = client.db(resource.resourceName);
|
||||
|
||||
// Drop the user
|
||||
try {
|
||||
await db.command({ dropUser: credentials.username });
|
||||
logger.info(`Dropped MongoDB user '${credentials.username}'`);
|
||||
} catch (e) {
|
||||
logger.warn(`Could not drop MongoDB user: ${e.message}`);
|
||||
}
|
||||
|
||||
// Drop the database
|
||||
await db.dropDatabase();
|
||||
logger.success(`MongoDB database '${resource.resourceName}' dropped`);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,30 +107,6 @@ export class RegistryManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a push/pull token for a service
|
||||
*/
|
||||
async createServiceToken(serviceName: string): Promise<string> {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('Registry not initialized');
|
||||
}
|
||||
|
||||
const repository = serviceName;
|
||||
const scopes = [
|
||||
`oci:repository:${repository}:push`,
|
||||
`oci:repository:${repository}:pull`,
|
||||
];
|
||||
|
||||
// Create OCI JWT token (expires in 1 year = 365 * 24 * 60 * 60 seconds)
|
||||
const token = await this.registry.authManager.createOciToken(
|
||||
'onebox',
|
||||
scopes,
|
||||
31536000 // 365 days in seconds
|
||||
);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags for a repository
|
||||
*/
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
* Orchestrates service deployment: Docker + Nginx + DNS + SSL
|
||||
*/
|
||||
|
||||
import type { IService, IServiceDeployOptions } from '../types.ts';
|
||||
import type { IService, IServiceDeployOptions, IPlatformRequirements } from '../types.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { OneboxDatabase } from './database.ts';
|
||||
import { OneboxDockerManager } from './docker.ts';
|
||||
import type { PlatformServicesManager } from './platform-services/index.ts';
|
||||
|
||||
export class OneboxServicesManager {
|
||||
private oneboxRef: any; // Will be Onebox instance
|
||||
@@ -34,13 +35,9 @@ export class OneboxServicesManager {
|
||||
}
|
||||
|
||||
// Handle Onebox Registry setup
|
||||
let registryToken: string | undefined;
|
||||
let imageToPull: string;
|
||||
|
||||
if (options.useOneboxRegistry) {
|
||||
// Generate registry token
|
||||
registryToken = await this.oneboxRef.registry.createServiceToken(options.name);
|
||||
|
||||
// Use onebox registry image name
|
||||
const tag = options.registryImageTag || 'latest';
|
||||
imageToPull = this.oneboxRef.registry.getImageName(options.name, tag);
|
||||
@@ -49,6 +46,15 @@ export class OneboxServicesManager {
|
||||
imageToPull = options.image;
|
||||
}
|
||||
|
||||
// Build platform requirements
|
||||
const platformRequirements: IPlatformRequirements | undefined =
|
||||
(options.enableMongoDB || options.enableS3)
|
||||
? {
|
||||
mongodb: options.enableMongoDB,
|
||||
s3: options.enableS3,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Create service record in database
|
||||
const service = await this.database.createService({
|
||||
name: options.name,
|
||||
@@ -63,18 +69,46 @@ export class OneboxServicesManager {
|
||||
// Onebox Registry fields
|
||||
useOneboxRegistry: options.useOneboxRegistry,
|
||||
registryRepository: options.useOneboxRegistry ? options.name : undefined,
|
||||
registryToken: registryToken,
|
||||
registryImageTag: options.registryImageTag || 'latest',
|
||||
autoUpdateOnPush: options.autoUpdateOnPush,
|
||||
// Platform requirements
|
||||
platformRequirements,
|
||||
});
|
||||
|
||||
// Provision platform resources if needed
|
||||
let platformEnvVars: Record<string, string> = {};
|
||||
if (platformRequirements) {
|
||||
try {
|
||||
logger.info(`Provisioning platform resources for service '${options.name}'...`);
|
||||
const platformServices = this.oneboxRef.platformServices as PlatformServicesManager;
|
||||
platformEnvVars = await platformServices.provisionForService(service);
|
||||
logger.success(`Platform resources provisioned for service '${options.name}'`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to provision platform resources: ${error.message}`);
|
||||
// Clean up the service record on failure
|
||||
this.database.deleteService(service.id!);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge platform env vars with user-specified env vars (user vars take precedence)
|
||||
const mergedEnvVars = { ...platformEnvVars, ...(options.envVars || {}) };
|
||||
|
||||
// Update service with merged env vars
|
||||
if (Object.keys(platformEnvVars).length > 0) {
|
||||
this.database.updateService(service.id!, { envVars: mergedEnvVars });
|
||||
}
|
||||
|
||||
// Get updated service with merged env vars
|
||||
const serviceWithEnvVars = this.database.getServiceByName(options.name)!;
|
||||
|
||||
// Pull image (skip if using onebox registry - image might not exist yet)
|
||||
if (!options.useOneboxRegistry) {
|
||||
await this.docker.pullImage(imageToPull, options.registry);
|
||||
}
|
||||
|
||||
// Create container
|
||||
const containerID = await this.docker.createContainer(service);
|
||||
// Create container (uses the updated service with merged env vars)
|
||||
const containerID = await this.docker.createContainer(serviceWithEnvVars);
|
||||
|
||||
// Update service with container ID
|
||||
this.database.updateService(service.id!, {
|
||||
@@ -293,6 +327,19 @@ export class OneboxServicesManager {
|
||||
// as they might be used by other services or need manual cleanup
|
||||
}
|
||||
|
||||
// Cleanup platform resources (MongoDB databases, S3 buckets, etc.)
|
||||
if (service.platformRequirements) {
|
||||
try {
|
||||
logger.info(`Cleaning up platform resources for service '${name}'...`);
|
||||
const platformServices = this.oneboxRef.platformServices as PlatformServicesManager;
|
||||
await platformServices.cleanupForService(service.id!);
|
||||
logger.success(`Platform resources cleaned up for service '${name}'`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to cleanup platform resources: ${error.message}`);
|
||||
// Continue with service deletion even if cleanup fails
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
this.database.deleteService(service.id!);
|
||||
|
||||
@@ -392,6 +439,28 @@ export class OneboxServicesManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform resources for a service
|
||||
*/
|
||||
async getServicePlatformResources(name: string) {
|
||||
try {
|
||||
const service = this.database.getServiceByName(name);
|
||||
if (!service) {
|
||||
throw new Error(`Service not found: ${name}`);
|
||||
}
|
||||
|
||||
if (!service.platformRequirements) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const platformServices = this.oneboxRef.platformServices as PlatformServicesManager;
|
||||
return await platformServices.getResourcesForService(service.id!);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get platform resources for service ${name}: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service status
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import { OneboxDatabase } from './database.ts';
|
||||
import { SqliteCertManager } from './certmanager.ts';
|
||||
|
||||
@@ -77,7 +78,7 @@ export class OneboxSslManager {
|
||||
|
||||
logger.success('SSL manager initialized with SmartACME DNS-01 challenge');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to initialize SSL manager: ${error.message}`);
|
||||
logger.error(`Failed to initialize SSL manager: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -121,16 +122,23 @@ export class OneboxSslManager {
|
||||
// Reload certificates in reverse proxy
|
||||
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||
|
||||
// Return certificate data
|
||||
// The certManager stores the cert to disk and database during getCertificateForDomain
|
||||
// Look up the paths from the database
|
||||
const dbCert = this.database.getSSLCertificate(domain);
|
||||
if (!dbCert) {
|
||||
throw new Error(`Certificate stored but not found in database for ${domain}`);
|
||||
}
|
||||
|
||||
// Return certificate data from database
|
||||
return {
|
||||
certPath: cert.certFilePath,
|
||||
keyPath: cert.keyFilePath,
|
||||
fullChainPath: cert.chainFilePath || cert.certFilePath,
|
||||
certPath: dbCert.certPath,
|
||||
keyPath: dbCert.keyPath,
|
||||
fullChainPath: dbCert.fullChainPath,
|
||||
expiryDate: cert.validUntil,
|
||||
issuer: cert.issuer || 'Let\'s Encrypt',
|
||||
issuer: dbCert.issuer || 'Let\'s Encrypt',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to acquire certificate for ${domain}: ${error.message}`);
|
||||
logger.error(`Failed to acquire certificate for ${domain}: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -164,7 +172,7 @@ export class OneboxSslManager {
|
||||
// Reload certificates in reverse proxy
|
||||
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to obtain certificate for ${domain}: ${error.message}`);
|
||||
logger.error(`Failed to obtain certificate for ${domain}: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -203,7 +211,7 @@ export class OneboxSslManager {
|
||||
|
||||
logger.success(`Certbot obtained certificate for ${domain}`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to run certbot: ${error.message}`);
|
||||
throw new Error(`Failed to run certbot: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +235,7 @@ export class OneboxSslManager {
|
||||
// Reload certificates in reverse proxy
|
||||
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to renew certificate for ${domain}: ${error.message}`);
|
||||
logger.error(`Failed to renew certificate for ${domain}: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -270,14 +278,14 @@ export class OneboxSslManager {
|
||||
await this.renewCertificate(dbCert.domain);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to renew ${dbCert.domain}: ${error.message}`);
|
||||
logger.error(`Failed to renew ${dbCert.domain}: ${getErrorMessage(error)}`);
|
||||
// Continue with other certificates
|
||||
}
|
||||
}
|
||||
|
||||
logger.success('Certificate renewal check complete');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to check expiring certificates: ${error.message}`);
|
||||
logger.error(`Failed to check expiring certificates: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -307,7 +315,7 @@ export class OneboxSslManager {
|
||||
// Reload certificates in reverse proxy
|
||||
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to renew all certificates: ${error.message}`);
|
||||
logger.error(`Failed to renew all certificates: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -358,7 +366,7 @@ export class OneboxSslManager {
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get certificate expiry for ${domain}: ${error.message}`);
|
||||
logger.error(`Failed to get certificate expiry for ${domain}: ${getErrorMessage(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
29
ts/cli.ts
29
ts/cli.ts
@@ -4,6 +4,7 @@
|
||||
|
||||
import { logger } from './logging.ts';
|
||||
import { projectInfo } from './info.ts';
|
||||
import { getErrorMessage } from './utils/error.ts';
|
||||
import { Onebox } from './classes/onebox.ts';
|
||||
import { OneboxDaemon } from './classes/daemon.ts';
|
||||
|
||||
@@ -80,7 +81,7 @@ export async function runCli(): Promise<void> {
|
||||
// Cleanup
|
||||
await onebox.shutdown();
|
||||
} catch (error) {
|
||||
logger.error(error.message);
|
||||
logger.error(getErrorMessage(error));
|
||||
Deno.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -227,20 +228,36 @@ async function handleSslCommand(onebox: Onebox, subcommand: string, args: string
|
||||
}
|
||||
}
|
||||
|
||||
// Nginx commands
|
||||
// Reverse proxy commands (formerly nginx commands)
|
||||
async function handleNginxCommand(onebox: Onebox, subcommand: string, _args: string[]) {
|
||||
switch (subcommand) {
|
||||
case 'reload':
|
||||
await onebox.nginx.reload();
|
||||
// Reload routes and certificates
|
||||
await onebox.reverseProxy.reloadRoutes();
|
||||
await onebox.reverseProxy.reloadCertificates();
|
||||
logger.success('Reverse proxy configuration reloaded');
|
||||
break;
|
||||
|
||||
case 'test':
|
||||
await onebox.nginx.test();
|
||||
// Verify reverse proxy is running
|
||||
const proxyStatus = onebox.reverseProxy.getStatus();
|
||||
if (proxyStatus.http.running || proxyStatus.https.running) {
|
||||
logger.success('Reverse proxy is running');
|
||||
logger.info(`HTTP: ${proxyStatus.http.running ? 'active' : 'inactive'} (port ${proxyStatus.http.port})`);
|
||||
logger.info(`HTTPS: ${proxyStatus.https.running ? 'active' : 'inactive'} (port ${proxyStatus.https.port})`);
|
||||
logger.info(`Routes: ${proxyStatus.routes}, Certificates: ${proxyStatus.https.certificates}`);
|
||||
} else {
|
||||
logger.error('Reverse proxy is not running');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'status': {
|
||||
const status = await onebox.nginx.getStatus();
|
||||
logger.info(`Nginx status: ${status}`);
|
||||
const status = onebox.reverseProxy.getStatus();
|
||||
logger.info(`Reverse proxy status:`);
|
||||
logger.info(` HTTP: ${status.http.running ? 'running' : 'stopped'} (port ${status.http.port})`);
|
||||
logger.info(` HTTPS: ${status.https.running ? 'running' : 'stopped'} (port ${status.https.port})`);
|
||||
logger.info(` Routes: ${status.routes}`);
|
||||
logger.info(` Certificates: ${status.https.certificates}`);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
96
ts/types.ts
96
ts/types.ts
@@ -18,10 +18,11 @@ export interface IService {
|
||||
// Onebox Registry fields
|
||||
useOneboxRegistry?: boolean;
|
||||
registryRepository?: string;
|
||||
registryToken?: string;
|
||||
registryImageTag?: string;
|
||||
autoUpdateOnPush?: boolean;
|
||||
imageDigest?: string;
|
||||
// Platform service requirements
|
||||
platformRequirements?: IPlatformRequirements;
|
||||
}
|
||||
|
||||
// Registry types
|
||||
@@ -33,6 +34,96 @@ export interface IRegistry {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// Registry token types
|
||||
export interface IRegistryToken {
|
||||
id?: number;
|
||||
name: string;
|
||||
tokenHash: string;
|
||||
type: 'global' | 'ci';
|
||||
scope: 'all' | string[]; // 'all' or array of service names
|
||||
expiresAt: number | null;
|
||||
createdAt: number;
|
||||
lastUsedAt: number | null;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface ICreateRegistryTokenRequest {
|
||||
name: string;
|
||||
type: 'global' | 'ci';
|
||||
scope: 'all' | string[];
|
||||
expiresIn: '30d' | '90d' | '365d' | 'never';
|
||||
}
|
||||
|
||||
export interface IRegistryTokenView {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'global' | 'ci';
|
||||
scope: 'all' | string[];
|
||||
scopeDisplay: string;
|
||||
expiresAt: number | null;
|
||||
createdAt: number;
|
||||
lastUsedAt: number | null;
|
||||
createdBy: string;
|
||||
isExpired: boolean;
|
||||
}
|
||||
|
||||
export interface ITokenCreatedResponse {
|
||||
token: IRegistryTokenView;
|
||||
plainToken: string; // Only shown once at creation
|
||||
}
|
||||
|
||||
// Platform service types
|
||||
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq';
|
||||
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
|
||||
export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||
|
||||
export interface IPlatformService {
|
||||
id?: number;
|
||||
name: string;
|
||||
type: TPlatformServiceType;
|
||||
status: TPlatformServiceStatus;
|
||||
containerId?: string;
|
||||
config: IPlatformServiceConfig;
|
||||
adminCredentialsEncrypted?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface IPlatformServiceConfig {
|
||||
image: string;
|
||||
port: number;
|
||||
volumes?: string[];
|
||||
command?: string;
|
||||
environment?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IPlatformResource {
|
||||
id?: number;
|
||||
platformServiceId: number;
|
||||
serviceId: number;
|
||||
resourceType: TPlatformResourceType;
|
||||
resourceName: string;
|
||||
credentialsEncrypted: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface IPlatformRequirements {
|
||||
mongodb?: boolean;
|
||||
s3?: boolean;
|
||||
}
|
||||
|
||||
export interface IProvisionedResource {
|
||||
type: TPlatformResourceType;
|
||||
name: string;
|
||||
credentials: Record<string, string>;
|
||||
envVars: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IEnvVarMapping {
|
||||
envVar: string;
|
||||
credentialPath: string;
|
||||
}
|
||||
|
||||
// Nginx configuration types
|
||||
export interface INginxConfig {
|
||||
id?: number;
|
||||
@@ -193,6 +284,9 @@ export interface IServiceDeployOptions {
|
||||
useOneboxRegistry?: boolean;
|
||||
registryImageTag?: string;
|
||||
autoUpdateOnPush?: boolean;
|
||||
// Platform service requirements
|
||||
enableMongoDB?: boolean;
|
||||
enableS3?: boolean;
|
||||
}
|
||||
|
||||
// HTTP API request/response types
|
||||
|
||||
43
ts/utils/error.ts
Normal file
43
ts/utils/error.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Error handling utilities for TypeScript strict mode compatibility
|
||||
*/
|
||||
|
||||
/**
|
||||
* Safely extract error message from unknown error type
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract error stack from unknown error type
|
||||
*/
|
||||
export function getErrorStack(error: unknown): string | undefined {
|
||||
if (error instanceof Error) {
|
||||
return error.stack;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract error name from unknown error type
|
||||
*/
|
||||
export function getErrorName(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.name;
|
||||
}
|
||||
return 'Error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a specific error type by name
|
||||
*/
|
||||
export function isErrorType(error: unknown, name: string): boolean {
|
||||
if (error instanceof Error) {
|
||||
return error.name === name;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user