Skip to content

Commit 80f0fb4

Browse files
authored
Add volume mount parsing and configuration to CLI and Docker manager (#46)
* Add volume mount parsing and configuration to CLI and Docker manager Signed-off-by: Jiaxiao (mossaka) Zhou <duibao55328@gmail.com> * add more tests Signed-off-by: Jiaxiao (mossaka) Zhou <duibao55328@gmail.com> --------- Signed-off-by: Jiaxiao (mossaka) Zhou <duibao55328@gmail.com>
1 parent e7b2257 commit 80f0fb4

File tree

8 files changed

+760
-22
lines changed

8 files changed

+760
-22
lines changed

src/cli.test.ts

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { Command } from 'commander';
2-
import { parseEnvironmentVariables, parseDomains, escapeShellArg, joinShellArgs } from './cli';
2+
import { parseEnvironmentVariables, parseDomains, escapeShellArg, joinShellArgs, parseVolumeMounts } from './cli';
33
import { redactSecrets } from './redact-secrets';
4+
import * as fs from 'fs';
5+
import * as path from 'path';
6+
import * as os from 'os';
47

58
describe('cli', () => {
69
describe('domain parsing', () => {
@@ -338,4 +341,177 @@ describe('cli', () => {
338341
expect(dir).toMatch(/^\/tmp\//);
339342
});
340343
});
344+
345+
describe('volume mount parsing', () => {
346+
let testDir: string;
347+
348+
beforeEach(() => {
349+
// Create a temporary directory for testing
350+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-test-'));
351+
});
352+
353+
afterEach(() => {
354+
// Clean up the test directory
355+
if (fs.existsSync(testDir)) {
356+
fs.rmSync(testDir, { recursive: true, force: true });
357+
}
358+
});
359+
360+
it('should parse valid mount with read-write mode', () => {
361+
const mounts = [`${testDir}:/workspace:rw`];
362+
const result = parseVolumeMounts(mounts);
363+
364+
expect(result.success).toBe(true);
365+
if (result.success) {
366+
expect(result.mounts).toEqual([`${testDir}:/workspace:rw`]);
367+
}
368+
});
369+
370+
it('should parse valid mount with read-only mode', () => {
371+
const mounts = [`${testDir}:/data:ro`];
372+
const result = parseVolumeMounts(mounts);
373+
374+
expect(result.success).toBe(true);
375+
if (result.success) {
376+
expect(result.mounts).toEqual([`${testDir}:/data:ro`]);
377+
}
378+
});
379+
380+
it('should parse valid mount without mode (defaults to rw)', () => {
381+
const mounts = [`${testDir}:/app`];
382+
const result = parseVolumeMounts(mounts);
383+
384+
expect(result.success).toBe(true);
385+
if (result.success) {
386+
expect(result.mounts).toEqual([`${testDir}:/app`]);
387+
}
388+
});
389+
390+
it('should parse multiple valid mounts', () => {
391+
const subdir1 = path.join(testDir, 'dir1');
392+
const subdir2 = path.join(testDir, 'dir2');
393+
fs.mkdirSync(subdir1);
394+
fs.mkdirSync(subdir2);
395+
396+
const mounts = [`${subdir1}:/workspace:ro`, `${subdir2}:/data:rw`];
397+
const result = parseVolumeMounts(mounts);
398+
399+
expect(result.success).toBe(true);
400+
if (result.success) {
401+
expect(result.mounts).toEqual([`${subdir1}:/workspace:ro`, `${subdir2}:/data:rw`]);
402+
}
403+
});
404+
405+
it('should reject mount with too few parts', () => {
406+
const mounts = ['/workspace'];
407+
const result = parseVolumeMounts(mounts);
408+
409+
expect(result.success).toBe(false);
410+
if (!result.success) {
411+
expect(result.invalidMount).toBe('/workspace');
412+
expect(result.reason).toContain('host_path:container_path[:mode]');
413+
}
414+
});
415+
416+
it('should reject mount with too many parts', () => {
417+
const mounts = [`${testDir}:/workspace:rw:extra`];
418+
const result = parseVolumeMounts(mounts);
419+
420+
expect(result.success).toBe(false);
421+
if (!result.success) {
422+
expect(result.invalidMount).toBe(`${testDir}:/workspace:rw:extra`);
423+
expect(result.reason).toContain('host_path:container_path[:mode]');
424+
}
425+
});
426+
427+
it('should reject mount with empty host path', () => {
428+
const mounts = [':/workspace:rw'];
429+
const result = parseVolumeMounts(mounts);
430+
431+
expect(result.success).toBe(false);
432+
if (!result.success) {
433+
expect(result.invalidMount).toBe(':/workspace:rw');
434+
expect(result.reason).toContain('Host path cannot be empty');
435+
}
436+
});
437+
438+
it('should reject mount with empty container path', () => {
439+
const mounts = [`${testDir}::rw`];
440+
const result = parseVolumeMounts(mounts);
441+
442+
expect(result.success).toBe(false);
443+
if (!result.success) {
444+
expect(result.invalidMount).toBe(`${testDir}::rw`);
445+
expect(result.reason).toContain('Container path cannot be empty');
446+
}
447+
});
448+
449+
it('should reject mount with relative host path', () => {
450+
const mounts = ['./relative/path:/workspace:rw'];
451+
const result = parseVolumeMounts(mounts);
452+
453+
expect(result.success).toBe(false);
454+
if (!result.success) {
455+
expect(result.invalidMount).toBe('./relative/path:/workspace:rw');
456+
expect(result.reason).toContain('Host path must be absolute');
457+
}
458+
});
459+
460+
it('should reject mount with relative container path', () => {
461+
const mounts = [`${testDir}:relative/path:rw`];
462+
const result = parseVolumeMounts(mounts);
463+
464+
expect(result.success).toBe(false);
465+
if (!result.success) {
466+
expect(result.invalidMount).toBe(`${testDir}:relative/path:rw`);
467+
expect(result.reason).toContain('Container path must be absolute');
468+
}
469+
});
470+
471+
it('should reject mount with invalid mode', () => {
472+
const mounts = [`${testDir}:/workspace:invalid`];
473+
const result = parseVolumeMounts(mounts);
474+
475+
expect(result.success).toBe(false);
476+
if (!result.success) {
477+
expect(result.invalidMount).toBe(`${testDir}:/workspace:invalid`);
478+
expect(result.reason).toContain('Mount mode must be either "ro" or "rw"');
479+
}
480+
});
481+
482+
it('should reject mount with non-existent host path', () => {
483+
const nonExistentPath = '/tmp/this-path-definitely-does-not-exist-12345';
484+
const mounts = [`${nonExistentPath}:/workspace:rw`];
485+
const result = parseVolumeMounts(mounts);
486+
487+
expect(result.success).toBe(false);
488+
if (!result.success) {
489+
expect(result.invalidMount).toBe(`${nonExistentPath}:/workspace:rw`);
490+
expect(result.reason).toContain('Host path does not exist');
491+
}
492+
});
493+
494+
it('should handle empty array', () => {
495+
const mounts: string[] = [];
496+
const result = parseVolumeMounts(mounts);
497+
498+
expect(result.success).toBe(true);
499+
if (result.success) {
500+
expect(result.mounts).toEqual([]);
501+
}
502+
});
503+
504+
it('should return error on first invalid entry', () => {
505+
const subdir = path.join(testDir, 'valid');
506+
fs.mkdirSync(subdir);
507+
508+
const mounts = [`${subdir}:/workspace:ro`, 'invalid-mount', `${testDir}:/data:rw`];
509+
const result = parseVolumeMounts(mounts);
510+
511+
expect(result.success).toBe(false);
512+
if (!result.success) {
513+
expect(result.invalidMount).toBe('invalid-mount');
514+
}
515+
});
516+
});
341517
});

src/cli.ts

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,28 @@ export interface ParseEnvError {
7171
invalidVar: string;
7272
}
7373

74+
/**
75+
* Result of parsing volume mounts
76+
*/
77+
export interface ParseVolumeMountsResult {
78+
success: true;
79+
mounts: string[];
80+
}
81+
82+
export interface ParseVolumeMountsError {
83+
success: false;
84+
invalidMount: string;
85+
reason: string;
86+
}
87+
7488
/**
7589
* Parses environment variables from an array of KEY=VALUE strings
7690
* @param envVars Array of environment variable strings in KEY=VALUE format
7791
* @returns ParseEnvResult with parsed key-value pairs on success, or ParseEnvError with the invalid variable on failure
7892
*/
7993
export function parseEnvironmentVariables(envVars: string[]): ParseEnvResult | ParseEnvError {
8094
const result: Record<string, string> = {};
81-
95+
8296
for (const envVar of envVars) {
8397
const match = envVar.match(/^([^=]+)=(.*)$/);
8498
if (!match) {
@@ -87,10 +101,102 @@ export function parseEnvironmentVariables(envVars: string[]): ParseEnvResult | P
87101
const [, key, value] = match;
88102
result[key] = value;
89103
}
90-
104+
91105
return { success: true, env: result };
92106
}
93107

108+
/**
109+
* Parses and validates volume mount specifications
110+
* @param mounts Array of volume mount strings in host_path:container_path[:mode] format
111+
* @returns ParseVolumeMountsResult on success, or ParseVolumeMountsError with details on failure
112+
*/
113+
export function parseVolumeMounts(mounts: string[]): ParseVolumeMountsResult | ParseVolumeMountsError {
114+
const result: string[] = [];
115+
116+
for (const mount of mounts) {
117+
// Parse mount specification: host_path:container_path[:mode]
118+
const parts = mount.split(':');
119+
120+
if (parts.length < 2 || parts.length > 3) {
121+
return {
122+
success: false,
123+
invalidMount: mount,
124+
reason: 'Mount must be in format host_path:container_path[:mode]'
125+
};
126+
}
127+
128+
const [hostPath, containerPath, mode] = parts;
129+
130+
// Validate host path is not empty
131+
if (!hostPath || hostPath.trim() === '') {
132+
return {
133+
success: false,
134+
invalidMount: mount,
135+
reason: 'Host path cannot be empty'
136+
};
137+
}
138+
139+
// Validate container path is not empty
140+
if (!containerPath || containerPath.trim() === '') {
141+
return {
142+
success: false,
143+
invalidMount: mount,
144+
reason: 'Container path cannot be empty'
145+
};
146+
}
147+
148+
// Validate host path is absolute
149+
if (!hostPath.startsWith('/')) {
150+
return {
151+
success: false,
152+
invalidMount: mount,
153+
reason: 'Host path must be absolute (start with /)'
154+
};
155+
}
156+
157+
// Validate container path is absolute
158+
if (!containerPath.startsWith('/')) {
159+
return {
160+
success: false,
161+
invalidMount: mount,
162+
reason: 'Container path must be absolute (start with /)'
163+
};
164+
}
165+
166+
// Validate mode if specified
167+
if (mode && mode !== 'ro' && mode !== 'rw') {
168+
return {
169+
success: false,
170+
invalidMount: mount,
171+
reason: 'Mount mode must be either "ro" or "rw"'
172+
};
173+
}
174+
175+
// Validate host path exists
176+
try {
177+
const fs = require('fs');
178+
if (!fs.existsSync(hostPath)) {
179+
return {
180+
success: false,
181+
invalidMount: mount,
182+
reason: `Host path does not exist: ${hostPath}`
183+
};
184+
}
185+
} catch (error) {
186+
return {
187+
success: false,
188+
invalidMount: mount,
189+
reason: `Failed to check host path: ${error}`
190+
};
191+
}
192+
193+
// Add to result list
194+
result.push(mount);
195+
}
196+
197+
return { success: true, mounts: result };
198+
}
199+
94200
const program = new Command();
95201

96202
program
@@ -142,6 +248,12 @@ program
142248
'Pass all host environment variables to container (excludes system vars like PATH, DOCKER_HOST)',
143249
false
144250
)
251+
.option(
252+
'-v, --mount <host_path:container_path[:mode]>',
253+
'Volume mount (can be specified multiple times). Format: host_path:container_path[:ro|rw]',
254+
(value, previous: string[] = []) => [...previous, value],
255+
[]
256+
)
145257
.argument('[args...]', 'Command and arguments to execute (use -- to separate from options)')
146258
.action(async (args: string[], options) => {
147259
// Require -- separator for passing command arguments
@@ -180,6 +292,19 @@ program
180292
additionalEnv = parsed.env;
181293
}
182294

295+
// Parse and validate volume mounts from --mount flags
296+
let volumeMounts: string[] | undefined = undefined;
297+
if (options.mount && Array.isArray(options.mount) && options.mount.length > 0) {
298+
const parsed = parseVolumeMounts(options.mount);
299+
if (!parsed.success) {
300+
logger.error(`Invalid volume mount: ${parsed.invalidMount}`);
301+
logger.error(`Reason: ${parsed.reason}`);
302+
process.exit(1);
303+
}
304+
volumeMounts = parsed.mounts;
305+
logger.debug(`Parsed ${volumeMounts.length} volume mount(s)`);
306+
}
307+
183308
const config: WrapperConfig = {
184309
allowedDomains,
185310
copilotCommand,
@@ -191,6 +316,7 @@ program
191316
imageTag: options.imageTag,
192317
additionalEnv: Object.keys(additionalEnv).length > 0 ? additionalEnv : undefined,
193318
envAll: options.envAll,
319+
volumeMounts,
194320
};
195321

196322
// Warn if --env-all is used

0 commit comments

Comments
 (0)