From f0bc08c7c219514ed87532709d626d11ff4499b5 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 26 Nov 2025 13:49:11 +0000 Subject: [PATCH] feat: Add Caddy platform service provider with core functionality and integration --- ts/classes/httpserver.ts | 19 +- ts/classes/platform-services/manager.ts | 2 + .../platform-services/providers/caddy.ts | 110 +++++ ts/types.ts | 2 +- ui/src/app/core/types/api.types.ts | 3 +- .../registries/registries.component.ts | 58 ++- .../services/services-list.component.ts | 407 +++++++++++++----- 7 files changed, 465 insertions(+), 136 deletions(-) create mode 100644 ts/classes/platform-services/providers/caddy.ts diff --git a/ts/classes/httpserver.ts b/ts/classes/httpserver.ts index bfb2007..1f1d3dc 100644 --- a/ts/classes/httpserver.ts +++ b/ts/classes/httpserver.ts @@ -284,13 +284,13 @@ export class OneboxHttpServer { // 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') { + } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)$/) && method === 'GET') { const type = path.split('/').pop()! as TPlatformServiceType; 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; 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; return await this.handleStopPlatformServiceRequest(type); } else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') { @@ -1144,6 +1144,7 @@ export class OneboxHttpServer { redis: 6379, postgresql: 5432, rabbitmq: 5672, + caddy: 80, }; return ports[type] || 0; } @@ -1260,12 +1261,15 @@ export class OneboxHttpServer { // Build response with provider info const result = providers.map((provider) => { 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 { type: provider.type, displayName: provider.displayName, resourceTypes: provider.resourceTypes, status: service?.status || 'not-deployed', containerId: service?.containerId, + isCore, createdAt: service?.createdAt, updatedAt: service?.updatedAt, }; @@ -1362,6 +1366,15 @@ export class OneboxHttpServer { }, 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}`); await this.oneboxRef.platformServices.stopPlatformService(type); diff --git a/ts/classes/platform-services/manager.ts b/ts/classes/platform-services/manager.ts index 6a1c0e0..b936d6f 100644 --- a/ts/classes/platform-services/manager.ts +++ b/ts/classes/platform-services/manager.ts @@ -14,6 +14,7 @@ import type { import type { IPlatformServiceProvider } from './providers/base.ts'; import { MongoDBProvider } from './providers/mongodb.ts'; import { MinioProvider } from './providers/minio.ts'; +import { CaddyProvider } from './providers/caddy.ts'; import { logger } from '../../logging.ts'; import { getErrorMessage } from '../../utils/error.ts'; import { credentialEncryption } from '../encryption.ts'; @@ -37,6 +38,7 @@ export class PlatformServicesManager { // Register providers this.registerProvider(new MongoDBProvider(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`); } diff --git a/ts/classes/platform-services/providers/caddy.ts b/ts/classes/platform-services/providers/caddy.ts new file mode 100644 index 0000000..f8e94e5 --- /dev/null +++ b/ts/classes/platform-services/providers/caddy.ts @@ -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 { + 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 { + 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 { + 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 { + throw new Error('Caddy does not provision resources for user services'); + } + + /** + * Caddy doesn't deprovision resources + */ + async deprovisionResource(_resource: IPlatformResource, _credentials: Record): Promise { + throw new Error('Caddy does not manage resources for user services'); + } +} diff --git a/ts/types.ts b/ts/types.ts index 64a23be..af0c953 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -73,7 +73,7 @@ export interface ITokenCreatedResponse { } // 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 TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; diff --git a/ui/src/app/core/types/api.types.ts b/ui/src/app/core/types/api.types.ts index b7a3d35..88619e1 100644 --- a/ui/src/app/core/types/api.types.ts +++ b/ui/src/app/core/types/api.types.ts @@ -16,7 +16,7 @@ export interface ILoginResponse { } // 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 TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue'; @@ -220,6 +220,7 @@ export interface IPlatformService { resourceTypes: TPlatformResourceType[]; status: TPlatformServiceStatus; containerId?: string; + isCore?: boolean; // true for core services like Caddy (cannot be stopped) createdAt?: number; updatedAt?: number; } diff --git a/ui/src/app/features/registries/registries.component.ts b/ui/src/app/features/registries/registries.component.ts index 5349003..a4f3992 100644 --- a/ui/src/app/features/registries/registries.component.ts +++ b/ui/src/app/features/registries/registries.component.ts @@ -31,6 +31,9 @@ import { DialogDescriptionComponent, DialogFooterComponent, } from '../../ui/dialog/dialog.component'; +import { TabsComponent, TabComponent } from '../../ui/tabs/tabs.component'; + +type TRegistriesTab = 'onebox' | 'external'; @Component({ selector: 'app-registries', @@ -59,6 +62,8 @@ import { DialogTitleComponent, DialogDescriptionComponent, DialogFooterComponent, + TabsComponent, + TabComponent, ], template: `
@@ -67,8 +72,17 @@ import {

Manage container image registries

- - + + + Onebox Registry + External Registries + + + + @switch (activeTab()) { + @case ('onebox') { + +
@@ -118,21 +132,22 @@ import {
-
+
+ } + @case ('external') { + +
+
+

External Registries

+

Add credentials for private Docker registries

+
+ +
- -
-
-

External Registries

-

Add credentials for private Docker registries

-
- -
- - - + + @if (loading() && registries().length === 0) {
@for (_ of [1,2]; track $index) { @@ -178,8 +193,10 @@ import { } - - + + + } + }
@@ -229,6 +246,7 @@ export class RegistriesComponent implements OnInit { private api = inject(ApiService); private toast = inject(ToastService); + activeTab = signal('onebox'); registries = signal([]); loading = signal(false); addDialogOpen = signal(false); @@ -237,6 +255,10 @@ export class RegistriesComponent implements OnInit { form: IRegistryCreate = { url: '', username: '', password: '' }; + setTab(tab: TRegistriesTab): void { + this.activeTab.set(tab); + } + ngOnInit(): void { this.loadRegistries(); } diff --git a/ui/src/app/features/services/services-list.component.ts b/ui/src/app/features/services/services-list.component.ts index 98d93fb..a6782fc 100644 --- a/ui/src/app/features/services/services-list.component.ts +++ b/ui/src/app/features/services/services-list.component.ts @@ -3,7 +3,7 @@ import { RouterLink } from '@angular/router'; import { ApiService } from '../../core/services/api.service'; import { WebSocketService } from '../../core/services/websocket.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 { CardComponent, CardHeaderComponent, @@ -29,6 +29,9 @@ import { DialogDescriptionComponent, DialogFooterComponent, } from '../../ui/dialog/dialog.component'; +import { TabsComponent, TabComponent } from '../../ui/tabs/tabs.component'; + +type TServicesTab = 'user' | 'system'; @Component({ selector: 'app-services-list', @@ -54,6 +57,8 @@ import { DialogTitleComponent, DialogDescriptionComponent, DialogFooterComponent, + TabsComponent, + TabComponent, ], template: `
@@ -61,122 +66,218 @@ import {

Services

-

Manage your deployed services

+

Manage your deployed and system services

- - - + @if (activeTab() === 'user') { + + + + }
- - - - @if (loading() && services().length === 0) { -
- @for (_ of [1,2,3]; track $index) { - + + + User Services + System Services + + + + @switch (activeTab()) { + @case ('user') { + + + + @if (loading() && services().length === 0) { +
+ @for (_ of [1,2,3]; track $index) { + + } +
+ } @else if (services().length === 0) { +
+ + + +

No services

+

Get started by deploying your first service.

+ + + +
+ } @else { + + + + Name + Image + Domain + Status + Actions + + + + @for (service of services(); track service.name) { + + + + {{ service.name }} + + + {{ service.image }} + + @if (service.domain) { + + {{ service.domain }} + + } @else { + - + } + + + + {{ service.status }} + + + +
+ @if (service.status === 'stopped' || service.status === 'failed') { + + } + @if (service.status === 'running') { + + + } + +
+
+
+ } +
+
} -
- } @else if (services().length === 0) { -
- - - -

No services

-

Get started by deploying your first service.

- - - -
- } @else { - - - - Name - Image - Domain - Status - Actions - - - - @for (service of services(); track service.name) { - - - - {{ service.name }} - - - {{ service.image }} - - @if (service.domain) { - - {{ service.domain }} - - } @else { - - - } - - - - {{ service.status }} - - - -
- @if (service.status === 'stopped' || service.status === 'failed') { - - } - @if (service.status === 'running') { - - - } - -
-
-
- } -
-
- } -
-
+ + + } + @case ('system') { + + + + @if (platformLoading() && platformServices().length === 0) { +
+ @for (_ of [1,2,3]; track $index) { + + } +
+ } @else if (platformServices().length === 0) { +
+ + + +

No system services

+

System services will appear here once configured.

+
+ } @else { + + + + Service + Type + Status + Actions + + + + @for (service of platformServices(); track service.type) { + + +
+ {{ service.displayName }} + @if (service.isCore) { + Core + } +
+
+ {{ service.type }} + + + {{ service.status }} + + + +
+ @if (service.isCore) { + Managed by Onebox + } @else { + @if (service.status === 'stopped' || service.status === 'not-deployed' || service.status === 'failed') { + + } + @if (service.status === 'running') { + + } + } +
+
+
+ } +
+
+ } +
+
+ } + }
@@ -201,12 +302,21 @@ export class ServicesListComponent implements OnInit { private ws = inject(WebSocketService); private toast = inject(ToastService); + // Tab state + activeTab = signal('user'); + + // User services services = signal([]); loading = signal(false); actionLoading = signal(null); deleteDialogOpen = signal(false); serviceToDelete = signal(null); + // Platform services + platformServices = signal([]); + platformLoading = signal(false); + platformActionLoading = signal(null); + constructor() { // React to WebSocket updates effect(() => { @@ -220,6 +330,11 @@ export class ServicesListComponent implements OnInit { ngOnInit(): void { this.loadServices(); + this.loadPlatformServices(); + } + + setTab(tab: TServicesTab): void { + this.activeTab.set(tab); } async loadServices(): Promise { @@ -329,4 +444,70 @@ export class ServicesListComponent implements OnInit { this.serviceToDelete.set(null); } } + + // Platform Service Methods + async loadPlatformServices(): Promise { + 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 { + 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 { + 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); + } + } }