feat(opsserver): add container workspace API and backend execution environment for services
This commit is contained in:
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
181
ts/opsserver/handlers/workspace.handler.ts
Normal file
181
ts/opsserver/handlers/workspace.handler.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user