feat(opsserver): add container workspace API and backend execution environment for services

This commit is contained in:
2026-03-18 02:22:45 +00:00
parent 3108408133
commit 5c48ae4156
12 changed files with 511 additions and 4 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/onebox',
version: '1.20.0',
version: '1.21.0',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
}

View File

@@ -857,7 +857,23 @@ export class OneboxDockerManager {
cmd: string[]
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
try {
const container = await this.dockerClient!.getContainerById(containerID);
let container: any = null;
try {
container = await this.dockerClient!.getContainerById(containerID);
} catch {
// Not a direct container ID — try Swarm service lookup
}
if (!container) {
const serviceContainerId = await this.getContainerIdForService(containerID);
if (serviceContainerId) {
try {
container = await this.dockerClient!.getContainerById(serviceContainerId);
} catch {
// Service container also not found
}
}
}
if (!container) {
throw new Error(`Container not found: ${containerID}`);

View File

@@ -23,6 +23,7 @@ export class OpsServer {
public schedulesHandler!: handlers.SchedulesHandler;
public settingsHandler!: handlers.SettingsHandler;
public logsHandler!: handlers.LogsHandler;
public workspaceHandler!: handlers.WorkspaceHandler;
constructor(oneboxRef: Onebox) {
this.oneboxRef = oneboxRef;
@@ -63,6 +64,7 @@ export class OpsServer {
this.schedulesHandler = new handlers.SchedulesHandler(this);
this.settingsHandler = new handlers.SettingsHandler(this);
this.logsHandler = new handlers.LogsHandler(this);
this.workspaceHandler = new handlers.WorkspaceHandler(this);
logger.success('OpsServer TypedRequest handlers initialized');
}

View File

@@ -11,3 +11,4 @@ export * from './backups.handler.ts';
export * from './schedules.handler.ts';
export * from './settings.handler.ts';
export * from './logs.handler.ts';
export * from './workspace.handler.ts';

View File

@@ -0,0 +1,181 @@
import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
import { getErrorMessage } from '../../utils/error.ts';
export class WorkspaceHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
/**
* Resolve a service name to a container ID (handling Swarm service IDs)
*/
private async resolveContainerId(serviceName: string): Promise<string> {
const service = this.opsServerRef.oneboxRef.services.getService(serviceName);
if (!service || !service.containerID) {
throw new plugins.typedrequest.TypedResponseError(`Service not found or has no container: ${serviceName}`);
}
return service.containerID;
}
private registerHandlers(): void {
// Read file from container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceReadFile>(
'workspaceReadFile',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
['cat', dataArg.path],
);
if (result.exitCode !== 0) {
throw new plugins.typedrequest.TypedResponseError(`Failed to read file: ${result.stderr || 'File not found'}`);
}
return { content: result.stdout };
},
),
);
// Write file to container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceWriteFile>(
'workspaceWriteFile',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
// Use sh -c with printf to write content (handles special characters)
const escaped = dataArg.content.replace(/'/g, "'\\''");
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
['sh', '-c', `printf '%s' '${escaped}' > ${dataArg.path}`],
);
if (result.exitCode !== 0) {
throw new plugins.typedrequest.TypedResponseError(`Failed to write file: ${result.stderr}`);
}
return {};
},
),
);
// Read directory from container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceReadDir>(
'workspaceReadDir',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
// Use ls with -1 -F to get entries with type indicators (/ for dirs)
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
['ls', '-1', '-F', dataArg.path],
);
if (result.exitCode !== 0) {
throw new plugins.typedrequest.TypedResponseError(`Failed to read directory: ${result.stderr}`);
}
const entries = result.stdout
.split('\n')
.filter((line) => line.trim())
.map((line) => {
const isDir = line.endsWith('/');
const name = isDir ? line.slice(0, -1) : line.replace(/[*@=|]$/, '');
const basePath = dataArg.path.endsWith('/') ? dataArg.path : dataArg.path + '/';
return {
type: (isDir ? 'directory' : 'file') as 'file' | 'directory',
name,
path: basePath + name,
};
});
return { entries };
},
),
);
// Create directory in container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceMkdir>(
'workspaceMkdir',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
['mkdir', '-p', dataArg.path],
);
if (result.exitCode !== 0) {
throw new plugins.typedrequest.TypedResponseError(`Failed to create directory: ${result.stderr}`);
}
return {};
},
),
);
// Remove file/directory from container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceRm>(
'workspaceRm',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
const args = dataArg.recursive ? ['rm', '-rf', dataArg.path] : ['rm', '-f', dataArg.path];
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
args,
);
if (result.exitCode !== 0) {
throw new plugins.typedrequest.TypedResponseError(`Failed to remove: ${result.stderr}`);
}
return {};
},
),
);
// Check if path exists in container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceExists>(
'workspaceExists',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
['test', '-e', dataArg.path],
);
return { exists: result.exitCode === 0 };
},
),
);
// Execute a command in the container (non-interactive)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceExec>(
'workspaceExec',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
const cmd = dataArg.args
? [dataArg.command, ...dataArg.args]
: [dataArg.command];
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
cmd,
);
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
};
},
),
);
logger.info('Workspace handler registered');
}
}