402 lines
16 KiB
TypeScript
402 lines
16 KiB
TypeScript
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 { requireAdminIdentity } from '../helpers/guards.ts';
|
|
import { getErrorMessage } from '../../utils/error.ts';
|
|
|
|
interface IWorkspaceProcessSession {
|
|
processId: string;
|
|
serviceName: string;
|
|
userId: string;
|
|
stream: plugins.nodeStream.Duplex;
|
|
close: () => Promise<void>;
|
|
inspect: () => Promise<{ ExitCode?: number | null; Running?: boolean }>;
|
|
finalized: boolean;
|
|
}
|
|
|
|
const getWorkspaceProcessTag = (processIdArg: string) => `workspaceProcess:${processIdArg}`;
|
|
|
|
export class WorkspaceHandler {
|
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
private workspaceProcesses = new Map<string, IWorkspaceProcessSession>();
|
|
|
|
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 validateProcessId(processIdArg: string): void {
|
|
if (!/^[a-zA-Z0-9_-]{8,80}$/.test(processIdArg)) {
|
|
throw new plugins.typedrequest.TypedResponseError('Invalid workspace process id');
|
|
}
|
|
}
|
|
|
|
private async getShellCommandForContainer(
|
|
containerIdArg: string,
|
|
): Promise<interfaces.requests.IWorkspaceShellCommand> {
|
|
const candidates: interfaces.requests.IWorkspaceShellCommand[] = [
|
|
{ command: '/bin/bash', args: ['-il'], label: 'bash', prompt: '# ' },
|
|
{ command: 'bash', args: ['-il'], label: 'bash', prompt: '# ' },
|
|
{ command: '/bin/sh', args: ['-i'], label: 'sh', prompt: '# ' },
|
|
{ command: 'sh', args: ['-i'], label: 'sh', prompt: '# ' },
|
|
{ command: '/bin/ash', args: ['-i'], label: 'ash', prompt: '# ' },
|
|
{ command: 'ash', args: ['-i'], label: 'ash', prompt: '# ' },
|
|
{ command: '/usr/bin/zsh', args: ['-il'], label: 'zsh', prompt: '# ' },
|
|
{ command: 'zsh', args: ['-il'], label: 'zsh', prompt: '# ' },
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
|
|
containerIdArg,
|
|
[candidate.command, '-c', 'printf onebox-shell'],
|
|
);
|
|
if (result.exitCode === 0 && result.stdout.includes('onebox-shell')) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
throw new plugins.typedrequest.TypedResponseError(
|
|
'No supported interactive shell found in the target container',
|
|
);
|
|
}
|
|
|
|
private async getProcessSession(
|
|
dataArg: { identity: interfaces.data.IIdentity; processId: string },
|
|
): Promise<IWorkspaceProcessSession> {
|
|
const identity = await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
|
this.validateProcessId(dataArg.processId);
|
|
const session = this.workspaceProcesses.get(dataArg.processId);
|
|
if (!session) {
|
|
throw new plugins.typedrequest.TypedResponseError(`Workspace process not found: ${dataArg.processId}`);
|
|
}
|
|
if (session.userId !== identity.userId) {
|
|
throw new plugins.typedrequest.TypedResponseError('Workspace process belongs to another session');
|
|
}
|
|
return session;
|
|
}
|
|
|
|
private async pushWorkspaceProcessOutput(processIdArg: string, outputArg: string): Promise<void> {
|
|
const typedsocket = (this.opsServerRef.server as any)?.typedserver?.typedsocket;
|
|
if (!typedsocket) return;
|
|
|
|
const connections = await typedsocket.findAllTargetConnectionsByTag(getWorkspaceProcessTag(processIdArg));
|
|
await Promise.allSettled(
|
|
connections.map((connection: any) => typedsocket
|
|
.createTypedRequest(
|
|
'pushWorkspaceProcessOutput',
|
|
connection,
|
|
)
|
|
.fire({ processId: processIdArg, output: outputArg })),
|
|
);
|
|
}
|
|
|
|
private async pushWorkspaceProcessExit(processIdArg: string, exitCodeArg: number): Promise<void> {
|
|
const typedsocket = (this.opsServerRef.server as any)?.typedserver?.typedsocket;
|
|
if (!typedsocket) return;
|
|
|
|
const connections = await typedsocket.findAllTargetConnectionsByTag(getWorkspaceProcessTag(processIdArg));
|
|
await Promise.allSettled(
|
|
connections.map((connection: any) => typedsocket
|
|
.createTypedRequest(
|
|
'pushWorkspaceProcessExit',
|
|
connection,
|
|
)
|
|
.fire({ processId: processIdArg, exitCode: exitCodeArg })),
|
|
);
|
|
}
|
|
|
|
private async finalizeWorkspaceProcess(processIdArg: string, fallbackExitCodeArg = -1): Promise<void> {
|
|
const session = this.workspaceProcesses.get(processIdArg);
|
|
if (!session || session.finalized) return;
|
|
session.finalized = true;
|
|
|
|
let exitCode = fallbackExitCodeArg;
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
const inspectResult = await session.inspect();
|
|
if (typeof inspectResult.ExitCode === 'number') {
|
|
exitCode = inspectResult.ExitCode;
|
|
}
|
|
} catch (error) {
|
|
logger.debug(`Failed to inspect workspace process ${processIdArg}: ${getErrorMessage(error)}`);
|
|
}
|
|
|
|
this.workspaceProcesses.delete(processIdArg);
|
|
await this.pushWorkspaceProcessExit(processIdArg, exitCode);
|
|
try {
|
|
await session.close();
|
|
} catch {
|
|
// The hijacked connection may already be closed by Docker.
|
|
}
|
|
}
|
|
|
|
private registerHandlers(): void {
|
|
// Read file from container
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceReadFile>(
|
|
'workspaceReadFile',
|
|
async (dataArg) => {
|
|
await requireAdminIdentity(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 requireAdminIdentity(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 requireAdminIdentity(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 requireAdminIdentity(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 requireAdminIdentity(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 requireAdminIdentity(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 requireAdminIdentity(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,
|
|
};
|
|
},
|
|
),
|
|
);
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceGetShellCommand>(
|
|
'workspaceGetShellCommand',
|
|
async (dataArg) => {
|
|
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
|
const containerId = await this.resolveContainerId(dataArg.serviceName);
|
|
const shellCommand = await this.getShellCommandForContainer(containerId);
|
|
return { shellCommand };
|
|
},
|
|
),
|
|
);
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceStartProcess>(
|
|
'workspaceStartProcess',
|
|
async (dataArg) => {
|
|
const identity = await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
|
this.validateProcessId(dataArg.processId);
|
|
if (this.workspaceProcesses.has(dataArg.processId)) {
|
|
throw new plugins.typedrequest.TypedResponseError(`Workspace process already exists: ${dataArg.processId}`);
|
|
}
|
|
|
|
const containerId = await this.resolveContainerId(dataArg.serviceName);
|
|
const command = dataArg.args ? [dataArg.command, ...dataArg.args] : [dataArg.command];
|
|
const interactiveExec = await this.opsServerRef.oneboxRef.docker.startInteractiveExecInContainer(
|
|
containerId,
|
|
command,
|
|
);
|
|
|
|
const session: IWorkspaceProcessSession = {
|
|
processId: dataArg.processId,
|
|
serviceName: dataArg.serviceName,
|
|
userId: identity.userId,
|
|
stream: interactiveExec.stream,
|
|
close: interactiveExec.close,
|
|
inspect: interactiveExec.inspect,
|
|
finalized: false,
|
|
};
|
|
this.workspaceProcesses.set(dataArg.processId, session);
|
|
|
|
interactiveExec.stream.on('data', (chunk: Uint8Array | string) => {
|
|
const output = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
|
|
void this.pushWorkspaceProcessOutput(dataArg.processId, output);
|
|
});
|
|
interactiveExec.stream.on('error', (error: Error) => {
|
|
void this.pushWorkspaceProcessOutput(
|
|
dataArg.processId,
|
|
`\r\n[workspace process error: ${getErrorMessage(error)}]\r\n`,
|
|
);
|
|
void this.finalizeWorkspaceProcess(dataArg.processId, -1);
|
|
});
|
|
interactiveExec.stream.on('end', () => {
|
|
void this.finalizeWorkspaceProcess(dataArg.processId, -1);
|
|
});
|
|
interactiveExec.stream.on('close', () => {
|
|
void this.finalizeWorkspaceProcess(dataArg.processId, -1);
|
|
});
|
|
|
|
return { processId: dataArg.processId };
|
|
},
|
|
),
|
|
);
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceProcessInput>(
|
|
'workspaceProcessInput',
|
|
async (dataArg) => {
|
|
const session = await this.getProcessSession(dataArg);
|
|
if (session.finalized || session.stream.writableEnded) {
|
|
return {};
|
|
}
|
|
await new Promise<void>((resolve, reject) => {
|
|
session.stream.write(dataArg.input, (error?: Error | null) => {
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
return {};
|
|
},
|
|
),
|
|
);
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceKillProcess>(
|
|
'workspaceKillProcess',
|
|
async (dataArg) => {
|
|
const session = await this.getProcessSession(dataArg);
|
|
session.stream.destroy();
|
|
try {
|
|
await session.close();
|
|
} catch {
|
|
// The stream may already be closed.
|
|
}
|
|
await this.finalizeWorkspaceProcess(dataArg.processId, -1);
|
|
return {};
|
|
},
|
|
),
|
|
);
|
|
|
|
logger.info('Workspace handler registered');
|
|
}
|
|
}
|