Skip to content

Commit 5a0c5db

Browse files
committed
feat(satellite): add tmpfs management for GitHub deployments
1 parent 9081438 commit 5a0c5db

File tree

6 files changed

+326
-59
lines changed

6 files changed

+326
-59
lines changed

services/satellite/.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,21 @@ NSJAIL_RLIMIT_FSIZE=50
119119
# Tmpfs size for /tmp directory (default: 100M, sufficient for npm cache)
120120
NSJAIL_TMPFS_SIZE=100M
121121

122+
# GitHub Deployment tmpfs Configuration
123+
# Tmpfs quota for GitHub deployment working directories (default: 300M)
124+
# This is a kernel-enforced hard limit for npm install/pip install during deployment
125+
# If dependencies exceed this size, the process is killed immediately
126+
NSJAIL_DEPLOYMENT_TMPFS_SIZE=300M
127+
128+
# Base directory for GitHub deployment tmpfs mounts (default: /opt/mcp-deployments)
129+
# Each deployment gets a subdirectory: {base}/{team-id}/{installation-id}
130+
MCP_DEPLOYMENT_BASE_DIR=/opt/mcp-deployments
131+
132+
# Enable tmpfs in development mode (default: false)
133+
# By default, tmpfs is only used in production (NODE_ENV=production)
134+
# Set to 'true' to test tmpfs behavior in development
135+
MCP_USE_TMPFS=false
136+
122137
# Process Idle Timeout (stdio MCP servers only)
123138
# Idle stdio processes are automatically terminated after this duration to save resources
124139
# Processes are transparently respawned when API calls arrive (1-3s latency)

services/satellite/src/config/nsjail.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ export const nsjailConfig = {
3333
maxFileSizeMB: parseInt(process.env.NSJAIL_RLIMIT_FSIZE || '50', 10),
3434

3535
/** Tmpfs size for /tmp directory (default: 100M) */
36-
tmpfsSize: process.env.NSJAIL_TMPFS_SIZE || '100M'
36+
tmpfsSize: process.env.NSJAIL_TMPFS_SIZE || '100M',
37+
38+
/** Tmpfs size for GitHub deployment working directories (default: 300M) */
39+
deploymentTmpfsSize: process.env.NSJAIL_DEPLOYMENT_TMPFS_SIZE || '300M'
3740
};
3841

3942
/**
@@ -44,6 +47,13 @@ export const nsjailConfig = {
4447
*/
4548
export const mcpCacheBaseDir = process.env.HOME || '/opt/deploystack';
4649

50+
/**
51+
* GitHub Deployment Base Directory
52+
* Base directory for GitHub deployment tmpfs mounts
53+
* Default: /opt/mcp-deployments
54+
*/
55+
export const githubDeploymentBaseDir = process.env.MCP_DEPLOYMENT_BASE_DIR || '/opt/mcp-deployments';
56+
4757
/**
4858
* Blocked Environment Variables
4959
* These env vars are stripped from user-provided config before passing to nsjail.
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { spawn } from 'child_process';
2+
import { mkdir, rm } from 'fs/promises';
3+
import { Logger } from 'pino';
4+
5+
export interface TmpfsOptions {
6+
size: string; // e.g., '300M'
7+
mode?: string; // e.g., '0755'
8+
}
9+
10+
/**
11+
* Create and manage tmpfs mounts for GitHub deployments
12+
*/
13+
export class TmpfsManager {
14+
constructor(private logger: Logger) {}
15+
16+
/**
17+
* Create tmpfs mount at specified path
18+
*/
19+
async createTmpfs(mountPath: string, options: TmpfsOptions): Promise<void> {
20+
this.logger.debug({
21+
operation: 'tmpfs_create_start',
22+
mount_path: mountPath,
23+
size: options.size
24+
}, 'Creating tmpfs mount');
25+
26+
// Create mount point directory
27+
await mkdir(mountPath, { recursive: true, mode: options.mode || '0755' });
28+
29+
// Mount tmpfs with size limit
30+
return new Promise((resolve, reject) => {
31+
const mountArgs = [
32+
'-t', 'tmpfs',
33+
'-o', `size=${options.size}${options.mode ? `,mode=${options.mode}` : ''}`,
34+
'tmpfs',
35+
mountPath
36+
];
37+
38+
const proc = spawn('mount', mountArgs, { stdio: 'pipe' });
39+
40+
let stderr = '';
41+
proc.stderr.on('data', (data) => {
42+
stderr += data.toString();
43+
});
44+
45+
proc.on('exit', (code) => {
46+
if (code === 0) {
47+
this.logger.info({
48+
operation: 'tmpfs_create_success',
49+
mount_path: mountPath,
50+
size: options.size
51+
}, `tmpfs created: ${mountPath} (${options.size})`);
52+
resolve();
53+
} else {
54+
this.logger.error({
55+
operation: 'tmpfs_create_failed',
56+
mount_path: mountPath,
57+
exit_code: code,
58+
stderr: stderr.trim()
59+
}, 'Failed to create tmpfs');
60+
reject(new Error(`Failed to create tmpfs: ${stderr.trim()}`));
61+
}
62+
});
63+
64+
proc.on('error', (error) => {
65+
this.logger.error({
66+
operation: 'tmpfs_create_error',
67+
mount_path: mountPath,
68+
error: error.message
69+
}, 'tmpfs mount command error');
70+
reject(new Error(`tmpfs mount error: ${error.message}`));
71+
});
72+
});
73+
}
74+
75+
/**
76+
* Unmount and remove tmpfs
77+
*/
78+
async removeTmpfs(mountPath: string): Promise<void> {
79+
this.logger.debug({
80+
operation: 'tmpfs_remove_start',
81+
mount_path: mountPath
82+
}, 'Removing tmpfs mount');
83+
84+
return new Promise((resolve, reject) => {
85+
const proc = spawn('umount', [mountPath], { stdio: 'pipe' });
86+
87+
let stderr = '';
88+
proc.stderr.on('data', (data) => {
89+
stderr += data.toString();
90+
});
91+
92+
proc.on('exit', async (code) => {
93+
if (code === 0) {
94+
// Remove mount point directory
95+
try {
96+
await rm(mountPath, { recursive: true, force: true });
97+
this.logger.info({
98+
operation: 'tmpfs_remove_success',
99+
mount_path: mountPath
100+
}, 'tmpfs removed successfully');
101+
resolve();
102+
} catch (error) {
103+
this.logger.warn({
104+
operation: 'tmpfs_rmdir_failed',
105+
mount_path: mountPath,
106+
error: error instanceof Error ? error.message : String(error)
107+
}, 'Failed to remove mount point directory');
108+
resolve(); // Don't fail if directory removal fails
109+
}
110+
} else {
111+
this.logger.error({
112+
operation: 'tmpfs_remove_failed',
113+
mount_path: mountPath,
114+
exit_code: code,
115+
stderr: stderr.trim()
116+
}, 'Failed to unmount tmpfs');
117+
reject(new Error(`Failed to unmount tmpfs: ${stderr.trim()}`));
118+
}
119+
});
120+
121+
proc.on('error', (error) => {
122+
this.logger.error({
123+
operation: 'tmpfs_remove_error',
124+
mount_path: mountPath,
125+
error: error.message
126+
}, 'tmpfs unmount command error');
127+
reject(new Error(`tmpfs unmount error: ${error.message}`));
128+
});
129+
});
130+
}
131+
132+
/**
133+
* Check if path is a tmpfs mount
134+
*/
135+
async isTmpfs(mountPath: string): Promise<boolean> {
136+
return new Promise((resolve) => {
137+
const proc = spawn('mount', { stdio: 'pipe' });
138+
139+
let stdout = '';
140+
proc.stdout.on('data', (data) => {
141+
stdout += data.toString();
142+
});
143+
144+
proc.on('exit', () => {
145+
// Check if mount path appears in mount output with type tmpfs
146+
const isMounted = stdout.split('\n').some(line =>
147+
line.includes(mountPath) && line.includes('type tmpfs')
148+
);
149+
resolve(isMounted);
150+
});
151+
152+
proc.on('error', () => {
153+
resolve(false);
154+
});
155+
});
156+
}
157+
}

0 commit comments

Comments
 (0)