mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +00:00
refactor: pivot from ssh proxy to distributed api model
- Delete SSHFileAdapter, IFileAdapter, LocalFileAdapter (SSH/SFTP stack)
- Remove ssh2, ssh2-sftp-client dependencies; add http-proxy-middleware, http-proxy
- NodeRegistry: remote nodes no longer use Dockerode TCP; new getProxyTarget() returns {apiUrl, apiToken}
- NodeRegistry.testConnection: remote nodes use HTTP GET /api/auth/check instead of docker.info()
- DatabaseService: Node interface swaps SSH/TLS fields for api_url + api_token; legacy columns preserved for DB compat
- FileSystemService: reverted to clean local-only fs.promises; adapter pattern fully removed
- ComposeService: executeRemote() and SSH log streaming deleted; local-only execution remains
- index.ts: add /api/auth/generate-node-token endpoint (long-lived JWT, scope:node_proxy)
- index.ts: authMiddleware now accepts Bearer token in addition to cookie (Sencho-to-Sencho auth)
- index.ts: remote HTTP proxy middleware intercepts all /api/ requests for remote nodes, strips x-node-id, injects Authorization header, proxies to api_url
- index.ts: WS upgrade handler proxies WebSocket connections for remote nodes via http-proxy wsProxyServer
- NodeManager.tsx: form reduced to Name, API URL, API Token; Generate Node Token button added inline
- NodeContext.tsx: Node interface updated to api_url/api_token
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
import { spawn, exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import WebSocket from 'ws';
|
||||
import DockerController from './DockerController';
|
||||
import { LogFormatter } from './LogFormatter';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { Client as SSHClient } from 'ssh2';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* ComposeService — local docker compose CLI execution.
|
||||
*
|
||||
* In the Distributed API model, remote node compose operations are handled
|
||||
* by the remote Sencho instance. This service only executes commands locally.
|
||||
*/
|
||||
export class ComposeService {
|
||||
private baseDir: string;
|
||||
private nodeId: number;
|
||||
@@ -22,30 +24,10 @@ export class ComposeService {
|
||||
return new ComposeService(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal command execution (Local or Remote SSH)
|
||||
*/
|
||||
private async executeCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
ws?: WebSocket,
|
||||
throwOnError = true
|
||||
): Promise<void> {
|
||||
const node = NodeRegistry.getInstance().getNode(this.nodeId);
|
||||
if (!node) throw new Error(`Node ${this.nodeId} not found`);
|
||||
|
||||
if (node.type === 'local' || !node.host) {
|
||||
return this.executeLocal(command, args, cwd, ws, throwOnError);
|
||||
} else {
|
||||
return this.executeRemote(node, command, args, cwd, ws, throwOnError);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeLocal(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
private execute(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
ws?: WebSocket,
|
||||
throwOnError = true
|
||||
): Promise<void> {
|
||||
@@ -90,65 +72,9 @@ export class ComposeService {
|
||||
});
|
||||
}
|
||||
|
||||
private async executeRemote(
|
||||
node: any,
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
ws?: WebSocket,
|
||||
throwOnError = true
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new SSHClient();
|
||||
let errorLog = '';
|
||||
|
||||
conn.on('ready', () => {
|
||||
const cmdString = `cd "${cwd}" && ${command} ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
|
||||
conn.exec(cmdString, (err, stream) => {
|
||||
if (err) {
|
||||
conn.end();
|
||||
if (throwOnError) reject(err); else resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const onData = (data: any) => {
|
||||
const text = data.toString();
|
||||
errorLog += text;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(text);
|
||||
}
|
||||
};
|
||||
|
||||
stream.on('data', onData).stderr.on('data', onData);
|
||||
|
||||
stream.on('close', (code: any, signal: any) => {
|
||||
conn.end();
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(`Command exited with code ${code}\n`);
|
||||
}
|
||||
if (code === 0) resolve();
|
||||
else if (throwOnError) reject(new Error(errorLog.trim() || `Command failed with code ${code}`));
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}).on('error', (err) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(`SSH Error: ${err.message}\n`);
|
||||
if (throwOnError) reject(err); else resolve();
|
||||
}).connect({
|
||||
host: node.host,
|
||||
port: node.ssh_port || 22,
|
||||
username: node.ssh_user!,
|
||||
password: node.ssh_password,
|
||||
privateKey: node.ssh_key,
|
||||
readyTimeout: 10000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket): Promise<void> {
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
await this.executeCommand('docker', ['compose', action], stackDir, ws);
|
||||
await this.execute('docker', ['compose', action], stackDir, ws);
|
||||
}
|
||||
|
||||
async deployStack(stackName: string, ws?: WebSocket): Promise<void> {
|
||||
@@ -165,7 +91,7 @@ export class ComposeService {
|
||||
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
|
||||
}
|
||||
|
||||
await this.executeCommand('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
|
||||
|
||||
// Post-Deploy Health Probe
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
@@ -231,13 +157,10 @@ export class ComposeService {
|
||||
|
||||
let activeProcesses = 0;
|
||||
let streamEndedHandled = false;
|
||||
|
||||
let clientConnections: any[] = [];
|
||||
let localProcesses: ReturnType<typeof spawn>[] = [];
|
||||
|
||||
const onWsClose = () => {
|
||||
localProcesses.forEach(cp => { try { cp.kill(); } catch {} });
|
||||
clientConnections.forEach(conn => { try { conn.end(); } catch {} });
|
||||
};
|
||||
|
||||
ws.on('close', onWsClose);
|
||||
@@ -253,80 +176,43 @@ export class ComposeService {
|
||||
}
|
||||
};
|
||||
|
||||
const node = NodeRegistry.getInstance().getNode(this.nodeId);
|
||||
const isRemote = (node && node.type === 'remote' && node.host);
|
||||
|
||||
for (const container of containersToLog) {
|
||||
const containerName = container.Names?.[0]?.replace(/^\//, '') || container.Id;
|
||||
activeProcesses++;
|
||||
let lineBuffer = '';
|
||||
|
||||
const sendOutput = (data: Buffer | any) => {
|
||||
const sendOutput = (data: Buffer) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
lineBuffer += data.toString();
|
||||
const lines = lineBuffer.split(/\r?\n/);
|
||||
lineBuffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
const formattedLine = LogFormatter.process(line);
|
||||
ws.send(formattedLine + '\r\n');
|
||||
ws.send(LogFormatter.process(line) + '\r\n');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (lineBuffer && ws.readyState === WebSocket.OPEN) {
|
||||
const formattedLine = LogFormatter.process(lineBuffer);
|
||||
ws.send(formattedLine + '\r\n');
|
||||
lineBuffer = '';
|
||||
if (lineBuffer && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(LogFormatter.process(lineBuffer) + '\r\n');
|
||||
lineBuffer = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isRemote) {
|
||||
const conn = new SSHClient();
|
||||
clientConnections.push(conn);
|
||||
conn.on('ready', () => {
|
||||
conn.exec(`docker logs -f --tail 100 "${containerName}"`, {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
}
|
||||
}, (err, stream) => {
|
||||
if (err) {
|
||||
conn.end();
|
||||
handleProcessEnd();
|
||||
return;
|
||||
}
|
||||
stream.on('data', sendOutput).stderr.on('data', sendOutput);
|
||||
stream.on('close', () => {
|
||||
flushBuffer();
|
||||
conn.end();
|
||||
handleProcessEnd();
|
||||
});
|
||||
});
|
||||
}).on('error', handleProcessEnd).connect({
|
||||
host: node!.host,
|
||||
port: node!.ssh_port || 22,
|
||||
username: node!.ssh_user!,
|
||||
password: node!.ssh_password,
|
||||
privateKey: node!.ssh_key,
|
||||
readyTimeout: 10000,
|
||||
});
|
||||
} else {
|
||||
const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
}
|
||||
});
|
||||
localProcesses.push(child);
|
||||
child.stdout.on('data', sendOutput);
|
||||
child.stderr.on('data', sendOutput);
|
||||
child.on('error', handleProcessEnd);
|
||||
child.on('close', () => {
|
||||
flushBuffer();
|
||||
handleProcessEnd();
|
||||
});
|
||||
}
|
||||
const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
}
|
||||
});
|
||||
localProcesses.push(child);
|
||||
child.stdout.on('data', sendOutput);
|
||||
child.stderr.on('data', sendOutput);
|
||||
child.on('error', handleProcessEnd);
|
||||
child.on('close', () => {
|
||||
flushBuffer();
|
||||
handleProcessEnd();
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isClosed && ws.readyState === WebSocket.OPEN) {
|
||||
@@ -360,17 +246,17 @@ export class ComposeService {
|
||||
}
|
||||
|
||||
sendOutput('=== Pulling latest images ===\n');
|
||||
await this.executeCommand('docker', ['compose', 'pull'], stackDir, ws);
|
||||
await this.execute('docker', ['compose', 'pull'], stackDir, ws);
|
||||
|
||||
sendOutput('=== Recreating containers ===\n');
|
||||
await this.executeCommand('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
|
||||
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
|
||||
sendOutput('=== Stack updated successfully ===\n');
|
||||
}
|
||||
|
||||
public async downStack(stackName: string): Promise<void> {
|
||||
const stackPath = path.join(this.baseDir, stackName);
|
||||
try {
|
||||
await this.executeCommand('docker', ['compose', 'down'], stackPath, undefined, false);
|
||||
await this.execute('docker', ['compose', 'down'], stackPath, undefined, false);
|
||||
} catch (error) {
|
||||
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user