feat: Add Caddy platform service provider with core functionality and integration

This commit is contained in:
2025-11-26 13:49:11 +00:00
parent c03e0e055c
commit f0bc08c7c2
7 changed files with 465 additions and 136 deletions

View File

@@ -284,13 +284,13 @@ export class OneboxHttpServer {
// Platform Services endpoints // Platform Services endpoints
} else if (path === '/api/platform-services' && method === 'GET') { } else if (path === '/api/platform-services' && method === 'GET') {
return await this.handleListPlatformServicesRequest(); return await this.handleListPlatformServicesRequest();
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)$/) && method === 'GET') { } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)$/) && method === 'GET') {
const type = path.split('/').pop()! as TPlatformServiceType; const type = path.split('/').pop()! as TPlatformServiceType;
return await this.handleGetPlatformServiceRequest(type); return await this.handleGetPlatformServiceRequest(type);
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)\/start$/) && method === 'POST') { } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/start$/) && method === 'POST') {
const type = path.split('/')[3] as TPlatformServiceType; const type = path.split('/')[3] as TPlatformServiceType;
return await this.handleStartPlatformServiceRequest(type); return await this.handleStartPlatformServiceRequest(type);
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)\/stop$/) && method === 'POST') { } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stop$/) && method === 'POST') {
const type = path.split('/')[3] as TPlatformServiceType; const type = path.split('/')[3] as TPlatformServiceType;
return await this.handleStopPlatformServiceRequest(type); return await this.handleStopPlatformServiceRequest(type);
} else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') { } else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') {
@@ -1144,6 +1144,7 @@ export class OneboxHttpServer {
redis: 6379, redis: 6379,
postgresql: 5432, postgresql: 5432,
rabbitmq: 5672, rabbitmq: 5672,
caddy: 80,
}; };
return ports[type] || 0; return ports[type] || 0;
} }
@@ -1260,12 +1261,15 @@ export class OneboxHttpServer {
// Build response with provider info // Build response with provider info
const result = providers.map((provider) => { const result = providers.map((provider) => {
const service = platformServices.find((s) => s.type === provider.type); const service = platformServices.find((s) => s.type === provider.type);
// Check if provider has isCore property (like CaddyProvider)
const isCore = 'isCore' in provider && (provider as any).isCore === true;
return { return {
type: provider.type, type: provider.type,
displayName: provider.displayName, displayName: provider.displayName,
resourceTypes: provider.resourceTypes, resourceTypes: provider.resourceTypes,
status: service?.status || 'not-deployed', status: service?.status || 'not-deployed',
containerId: service?.containerId, containerId: service?.containerId,
isCore,
createdAt: service?.createdAt, createdAt: service?.createdAt,
updatedAt: service?.updatedAt, updatedAt: service?.updatedAt,
}; };
@@ -1362,6 +1366,15 @@ export class OneboxHttpServer {
}, 404); }, 404);
} }
// Check if this is a core service that cannot be stopped
const isCore = 'isCore' in provider && (provider as any).isCore === true;
if (isCore) {
return this.jsonResponse({
success: false,
error: `${provider.displayName} is a core service and cannot be stopped`,
}, 400);
}
logger.info(`Stopping platform service: ${type}`); logger.info(`Stopping platform service: ${type}`);
await this.oneboxRef.platformServices.stopPlatformService(type); await this.oneboxRef.platformServices.stopPlatformService(type);

View File

@@ -14,6 +14,7 @@ import type {
import type { IPlatformServiceProvider } from './providers/base.ts'; import type { IPlatformServiceProvider } from './providers/base.ts';
import { MongoDBProvider } from './providers/mongodb.ts'; import { MongoDBProvider } from './providers/mongodb.ts';
import { MinioProvider } from './providers/minio.ts'; import { MinioProvider } from './providers/minio.ts';
import { CaddyProvider } from './providers/caddy.ts';
import { logger } from '../../logging.ts'; import { logger } from '../../logging.ts';
import { getErrorMessage } from '../../utils/error.ts'; import { getErrorMessage } from '../../utils/error.ts';
import { credentialEncryption } from '../encryption.ts'; import { credentialEncryption } from '../encryption.ts';
@@ -37,6 +38,7 @@ export class PlatformServicesManager {
// Register providers // Register providers
this.registerProvider(new MongoDBProvider(this.oneboxRef)); this.registerProvider(new MongoDBProvider(this.oneboxRef));
this.registerProvider(new MinioProvider(this.oneboxRef)); this.registerProvider(new MinioProvider(this.oneboxRef));
this.registerProvider(new CaddyProvider(this.oneboxRef));
logger.info(`Platform services manager initialized with ${this.providers.size} providers`); logger.info(`Platform services manager initialized with ${this.providers.size} providers`);
} }

View File

@@ -0,0 +1,110 @@
/**
* Caddy Platform Service Provider
*
* Caddy is a core infrastructure service that provides reverse proxy functionality.
* Unlike other platform services:
* - It doesn't provision resources for user services
* - It's started automatically by Onebox and cannot be stopped by users
* - It delegates to the existing CaddyManager for actual operations
*/
import { BasePlatformServiceProvider } from './base.ts';
import type {
IService,
IPlatformResource,
IPlatformServiceConfig,
IProvisionedResource,
IEnvVarMapping,
TPlatformServiceType,
TPlatformResourceType,
} from '../../../types.ts';
import { logger } from '../../../logging.ts';
import type { Onebox } from '../../onebox.ts';
export class CaddyProvider extends BasePlatformServiceProvider {
readonly type: TPlatformServiceType = 'caddy';
readonly displayName = 'Caddy Reverse Proxy';
readonly resourceTypes: TPlatformResourceType[] = []; // Caddy doesn't provision resources
readonly isCore = true; // Core infrastructure - cannot be stopped by users
constructor(oneboxRef: Onebox) {
super(oneboxRef);
}
getDefaultConfig(): IPlatformServiceConfig {
return {
image: 'caddy:2-alpine',
port: 80,
volumes: [],
environment: {},
};
}
getEnvVarMappings(): IEnvVarMapping[] {
// Caddy doesn't inject any env vars into user services
return [];
}
/**
* Deploy Caddy container - delegates to CaddyManager via reverseProxy
*/
async deployContainer(): Promise<string> {
logger.info('Starting Caddy via reverse proxy manager...');
// Get the reverse proxy which manages Caddy
const reverseProxy = this.oneboxRef.reverseProxy;
// Start reverse proxy (which starts Caddy)
await reverseProxy.startHttp();
// Get Caddy status to find container ID
const status = reverseProxy.getStatus();
// Update platform service record
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (platformService) {
this.oneboxRef.database.updatePlatformService(platformService.id!, {
status: 'running',
containerId: 'onebox-caddy', // Service name for Swarm services
});
}
logger.success('Caddy platform service started');
return 'onebox-caddy';
}
/**
* Stop Caddy container - NOT ALLOWED for core infrastructure
*/
async stopContainer(_containerId: string): Promise<void> {
throw new Error('Caddy is a core infrastructure service and cannot be stopped');
}
/**
* Check if Caddy is healthy via the reverse proxy
*/
async healthCheck(): Promise<boolean> {
try {
const reverseProxy = this.oneboxRef.reverseProxy;
const status = reverseProxy.getStatus();
return status.http.running;
} catch (error) {
logger.debug(`Caddy health check failed: ${error}`);
return false;
}
}
/**
* Caddy doesn't provision resources for user services
*/
async provisionResource(_userService: IService): Promise<IProvisionedResource> {
throw new Error('Caddy does not provision resources for user services');
}
/**
* Caddy doesn't deprovision resources
*/
async deprovisionResource(_resource: IPlatformResource, _credentials: Record<string, string>): Promise<void> {
throw new Error('Caddy does not manage resources for user services');
}
}

View File

@@ -73,7 +73,7 @@ export interface ITokenCreatedResponse {
} }
// Platform service types // Platform service types
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq'; export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy';
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue'; export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';

View File

@@ -16,7 +16,7 @@ export interface ILoginResponse {
} }
// Platform Service Types (defined early for use in ISystemStatus) // Platform Service Types (defined early for use in ISystemStatus)
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq'; export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy';
export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue'; export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
@@ -220,6 +220,7 @@ export interface IPlatformService {
resourceTypes: TPlatformResourceType[]; resourceTypes: TPlatformResourceType[];
status: TPlatformServiceStatus; status: TPlatformServiceStatus;
containerId?: string; containerId?: string;
isCore?: boolean; // true for core services like Caddy (cannot be stopped)
createdAt?: number; createdAt?: number;
updatedAt?: number; updatedAt?: number;
} }

View File

@@ -31,6 +31,9 @@ import {
DialogDescriptionComponent, DialogDescriptionComponent,
DialogFooterComponent, DialogFooterComponent,
} from '../../ui/dialog/dialog.component'; } from '../../ui/dialog/dialog.component';
import { TabsComponent, TabComponent } from '../../ui/tabs/tabs.component';
type TRegistriesTab = 'onebox' | 'external';
@Component({ @Component({
selector: 'app-registries', selector: 'app-registries',
@@ -59,6 +62,8 @@ import {
DialogTitleComponent, DialogTitleComponent,
DialogDescriptionComponent, DialogDescriptionComponent,
DialogFooterComponent, DialogFooterComponent,
TabsComponent,
TabComponent,
], ],
template: ` template: `
<div class="space-y-6"> <div class="space-y-6">
@@ -67,6 +72,15 @@ import {
<p class="text-muted-foreground">Manage container image registries</p> <p class="text-muted-foreground">Manage container image registries</p>
</div> </div>
<!-- Tabs -->
<ui-tabs class="block">
<ui-tab [active]="activeTab() === 'onebox'" (tabClick)="setTab('onebox')">Onebox Registry</ui-tab>
<ui-tab [active]="activeTab() === 'external'" (tabClick)="setTab('external')">External Registries</ui-tab>
</ui-tabs>
<!-- Tab Content -->
@switch (activeTab()) {
@case ('onebox') {
<!-- Onebox Registry Card --> <!-- Onebox Registry Card -->
<ui-card class="border-primary/50"> <ui-card class="border-primary/50">
<ui-card-header class="flex flex-row items-start justify-between space-y-0"> <ui-card-header class="flex flex-row items-start justify-between space-y-0">
@@ -119,7 +133,8 @@ import {
</div> </div>
</ui-card-content> </ui-card-content>
</ui-card> </ui-card>
}
@case ('external') {
<!-- External Registries Section --> <!-- External Registries Section -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -180,6 +195,8 @@ import {
} }
</ui-card-content> </ui-card-content>
</ui-card> </ui-card>
}
}
</div> </div>
<!-- Add Registry Dialog --> <!-- Add Registry Dialog -->
@@ -229,6 +246,7 @@ export class RegistriesComponent implements OnInit {
private api = inject(ApiService); private api = inject(ApiService);
private toast = inject(ToastService); private toast = inject(ToastService);
activeTab = signal<TRegistriesTab>('onebox');
registries = signal<IRegistry[]>([]); registries = signal<IRegistry[]>([]);
loading = signal(false); loading = signal(false);
addDialogOpen = signal(false); addDialogOpen = signal(false);
@@ -237,6 +255,10 @@ export class RegistriesComponent implements OnInit {
form: IRegistryCreate = { url: '', username: '', password: '' }; form: IRegistryCreate = { url: '', username: '', password: '' };
setTab(tab: TRegistriesTab): void {
this.activeTab.set(tab);
}
ngOnInit(): void { ngOnInit(): void {
this.loadRegistries(); this.loadRegistries();
} }

View File

@@ -3,7 +3,7 @@ import { RouterLink } from '@angular/router';
import { ApiService } from '../../core/services/api.service'; import { ApiService } from '../../core/services/api.service';
import { WebSocketService } from '../../core/services/websocket.service'; import { WebSocketService } from '../../core/services/websocket.service';
import { ToastService } from '../../core/services/toast.service'; import { ToastService } from '../../core/services/toast.service';
import { IService } from '../../core/types/api.types'; import { IService, IPlatformService, TPlatformServiceType } from '../../core/types/api.types';
import { import {
CardComponent, CardComponent,
CardHeaderComponent, CardHeaderComponent,
@@ -29,6 +29,9 @@ import {
DialogDescriptionComponent, DialogDescriptionComponent,
DialogFooterComponent, DialogFooterComponent,
} from '../../ui/dialog/dialog.component'; } from '../../ui/dialog/dialog.component';
import { TabsComponent, TabComponent } from '../../ui/tabs/tabs.component';
type TServicesTab = 'user' | 'system';
@Component({ @Component({
selector: 'app-services-list', selector: 'app-services-list',
@@ -54,6 +57,8 @@ import {
DialogTitleComponent, DialogTitleComponent,
DialogDescriptionComponent, DialogDescriptionComponent,
DialogFooterComponent, DialogFooterComponent,
TabsComponent,
TabComponent,
], ],
template: ` template: `
<div class="space-y-6"> <div class="space-y-6">
@@ -61,8 +66,9 @@ import {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-3xl font-bold tracking-tight">Services</h1> <h1 class="text-3xl font-bold tracking-tight">Services</h1>
<p class="text-muted-foreground">Manage your deployed services</p> <p class="text-muted-foreground">Manage your deployed and system services</p>
</div> </div>
@if (activeTab() === 'user') {
<a routerLink="/services/create"> <a routerLink="/services/create">
<button uiButton> <button uiButton>
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -71,9 +77,19 @@ import {
Deploy Service Deploy Service
</button> </button>
</a> </a>
}
</div> </div>
<!-- Services Table --> <!-- Tabs -->
<ui-tabs class="block">
<ui-tab [active]="activeTab() === 'user'" (tabClick)="setTab('user')">User Services</ui-tab>
<ui-tab [active]="activeTab() === 'system'" (tabClick)="setTab('system')">System Services</ui-tab>
</ui-tabs>
<!-- Tab Content -->
@switch (activeTab()) {
@case ('user') {
<!-- User Services Table -->
<ui-card> <ui-card>
<ui-card-content class="p-0"> <ui-card-content class="p-0">
@if (loading() && services().length === 0) { @if (loading() && services().length === 0) {
@@ -177,6 +193,91 @@ import {
} }
</ui-card-content> </ui-card-content>
</ui-card> </ui-card>
}
@case ('system') {
<!-- System Services Table -->
<ui-card>
<ui-card-content class="p-0">
@if (platformLoading() && platformServices().length === 0) {
<div class="p-6 space-y-4">
@for (_ of [1,2,3]; track $index) {
<ui-skeleton class="h-12 w-full" />
}
</div>
} @else if (platformServices().length === 0) {
<div class="p-12 text-center">
<svg class="mx-auto h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
<h3 class="mt-4 text-lg font-semibold">No system services</h3>
<p class="mt-2 text-sm text-muted-foreground">System services will appear here once configured.</p>
</div>
} @else {
<ui-table>
<ui-table-header>
<ui-table-row>
<ui-table-head>Service</ui-table-head>
<ui-table-head>Type</ui-table-head>
<ui-table-head>Status</ui-table-head>
<ui-table-head class="text-right">Actions</ui-table-head>
</ui-table-row>
</ui-table-header>
<ui-table-body>
@for (service of platformServices(); track service.type) {
<ui-table-row>
<ui-table-cell>
<div class="flex items-center gap-2">
<span class="font-medium">{{ service.displayName }}</span>
@if (service.isCore) {
<ui-badge variant="outline">Core</ui-badge>
}
</div>
</ui-table-cell>
<ui-table-cell class="text-muted-foreground">{{ service.type }}</ui-table-cell>
<ui-table-cell>
<ui-badge [variant]="getPlatformStatusVariant(service.status)">
{{ service.status }}
</ui-badge>
</ui-table-cell>
<ui-table-cell class="text-right">
<div class="flex items-center justify-end gap-2">
@if (service.isCore) {
<span class="text-xs text-muted-foreground">Managed by Onebox</span>
} @else {
@if (service.status === 'stopped' || service.status === 'not-deployed' || service.status === 'failed') {
<button
uiButton
variant="outline"
size="sm"
(click)="startPlatformService(service.type)"
[disabled]="platformActionLoading() === service.type"
>
Start
</button>
}
@if (service.status === 'running') {
<button
uiButton
variant="outline"
size="sm"
(click)="stopPlatformService(service.type)"
[disabled]="platformActionLoading() === service.type"
>
Stop
</button>
}
}
</div>
</ui-table-cell>
</ui-table-row>
}
</ui-table-body>
</ui-table>
}
</ui-card-content>
</ui-card>
}
}
</div> </div>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
@@ -201,12 +302,21 @@ export class ServicesListComponent implements OnInit {
private ws = inject(WebSocketService); private ws = inject(WebSocketService);
private toast = inject(ToastService); private toast = inject(ToastService);
// Tab state
activeTab = signal<TServicesTab>('user');
// User services
services = signal<IService[]>([]); services = signal<IService[]>([]);
loading = signal(false); loading = signal(false);
actionLoading = signal<string | null>(null); actionLoading = signal<string | null>(null);
deleteDialogOpen = signal(false); deleteDialogOpen = signal(false);
serviceToDelete = signal<IService | null>(null); serviceToDelete = signal<IService | null>(null);
// Platform services
platformServices = signal<IPlatformService[]>([]);
platformLoading = signal(false);
platformActionLoading = signal<TPlatformServiceType | null>(null);
constructor() { constructor() {
// React to WebSocket updates // React to WebSocket updates
effect(() => { effect(() => {
@@ -220,6 +330,11 @@ export class ServicesListComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.loadServices(); this.loadServices();
this.loadPlatformServices();
}
setTab(tab: TServicesTab): void {
this.activeTab.set(tab);
} }
async loadServices(): Promise<void> { async loadServices(): Promise<void> {
@@ -329,4 +444,70 @@ export class ServicesListComponent implements OnInit {
this.serviceToDelete.set(null); this.serviceToDelete.set(null);
} }
} }
// Platform Service Methods
async loadPlatformServices(): Promise<void> {
this.platformLoading.set(true);
try {
const response = await this.api.getPlatformServices();
if (response.success && response.data) {
this.platformServices.set(response.data);
}
} catch {
this.toast.error('Failed to load system services');
} finally {
this.platformLoading.set(false);
}
}
getPlatformStatusVariant(status: string): 'success' | 'destructive' | 'warning' | 'secondary' {
switch (status) {
case 'running':
return 'success';
case 'stopped':
case 'not-deployed':
return 'secondary';
case 'failed':
return 'destructive';
case 'starting':
case 'stopping':
return 'warning';
default:
return 'secondary';
}
}
async startPlatformService(type: TPlatformServiceType): Promise<void> {
this.platformActionLoading.set(type);
try {
const response = await this.api.startPlatformService(type);
if (response.success) {
this.toast.success(`Platform service "${type}" started`);
this.loadPlatformServices();
} else {
this.toast.error(response.error || 'Failed to start platform service');
}
} catch {
this.toast.error('Failed to start platform service');
} finally {
this.platformActionLoading.set(null);
}
}
async stopPlatformService(type: TPlatformServiceType): Promise<void> {
this.platformActionLoading.set(type);
try {
const response = await this.api.stopPlatformService(type);
if (response.success) {
this.toast.success(`Platform service "${type}" stopped`);
this.loadPlatformServices();
} else {
this.toast.error(response.error || 'Failed to stop platform service');
}
} catch {
this.toast.error('Failed to stop platform service');
} finally {
this.platformActionLoading.set(null);
}
}
} }