mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 10:46:51 +00:00
feat(stacks): browse and edit mounted volume files in the explorer (#1403)
* feat(stacks): browse and edit mounted volume files in the explorer Reposition the stack file explorer around runtime configuration access: discover a stack's declared mounts and expose each as a safe, stack-scoped file root. The explorer opens on a Volumes group (bind mounts and named Docker volumes) by default, with the stack source directory as a secondary group, on a "Files & Volumes" tab. - Discover roots from the rendered effective compose model; resolve named volumes to their Docker name and browse/edit them through the hardened helper container, with bind mounts handled directly when reachable. - Re-derive the allowed roots server-side on every file operation and match the client root id against them, so a request can never address a path the stack did not declare. Block dangerous host mounts and binds that overlap Sencho's managed directories; reject writes to read-only mounts. - Thread an optional root id through the existing file endpoints and an opaque, parseable optimistic-concurrency token through read, conflict, and write, for both filesystem and helper backends. - Keep compose and env file protection on the stack source root only. * fix(stacks): theme the Files & Volumes root switcher Replace the raw native select in the file-root switcher with the design system Select component. The native control did not honour the dark theme, so the panel rendered white with unreadable text. The themed Select gives a dark popover with grouped Volumes / Stack source labels and disabled items. * fix(stacks): contain the bind-root probe and de-taint the file-op error log Gate the volume-root bind probe's realpath/stat behind a compose-base containment check (mirroring the storage host-path probe) so they never run on an unvalidated host path; a source outside the compose dir is unreachable in the containerized deployment anyway and is reported non-accessible without touching the filesystem. Log the helper-backed file-op failure through a constant format string with sanitized arguments instead of an interpolated template literal. * fix(stacks): inline the bind-probe containment guard at the fs sinks The wrapped containment predicate was not recognized as a path barrier, so the bind probe's realpath/stat still flagged as uncontrolled-data-in-path. Inline the path.resolve + startsWith check directly at each filesystem sink (and re-check the resolved canonical before stat, so a within-base symlink that resolves outside the compose dir is also rejected). * fix(stacks): harden file-root lifecycle, upload race, and helper errors Address review findings on the Files & Volumes feature: - Invalidate the file-root allowlist on stack create/delete/import/from-git (wire StackFileRootsService.invalidateNode into invalidateNodeCaches), so a stack deleted and recreated under the same name cannot serve the old stack's roots from the 15s cache. - Use the atomic exclusive write for a non-overwrite upload so a file created by another writer after the existence check is not silently clobbered. - Let the helper's real cd errno through and map permission failures to 403 consistently across list/stat/read/write/mkdir/delete/pathKind, instead of reporting EACCES as 404/500; pathKind no longer reports a permission-denied parent as absent. - Document the realpath-then-open TOCTOU as a known, pre-existing limitation of every file op (O_NOFOLLOW is not viable because config volumes legitimately contain symlinks); the bind root is contained to the compose dir and the op requires stack:edit. - Docs: drop a missing screenshot reference and correct the protected-file delete behavior (stack-root compose/.env cannot be deleted via the explorer).
This commit is contained in:
@@ -1,18 +1,32 @@
|
||||
import { Writable } from 'stream';
|
||||
import path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import DockerController from './DockerController';
|
||||
|
||||
const HELPER_IMAGE = 'alpine:3.20';
|
||||
const VOLUME_MOUNT = '/v';
|
||||
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
||||
// Named-volume downloads are bounded (not chunk-streamed): the helper output is
|
||||
// buffered up to this size and a larger file is rejected rather than silently
|
||||
// truncated. The bound matches the upload limit so the in-memory footprint is no
|
||||
// worse than the existing multipart upload path.
|
||||
const DOWNLOAD_MAX_BYTES = 25 * 1024 * 1024;
|
||||
const EXEC_TIMEOUT_MS = 30_000;
|
||||
|
||||
// Containment guard run inside the helper after every `cd`. Even though
|
||||
// sanitizeRelPath strips `..`, a symlinked directory component could redirect
|
||||
// `cd` outside the mounted volume; `pwd -P` resolves symlinks so we can assert
|
||||
// the working directory is still under the volume root before touching anything.
|
||||
const ROOT_GUARD =
|
||||
`case "$(pwd -P)" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) echo "path escapes volume root" >&2; exit 7 ;; esac`;
|
||||
|
||||
// Portable shell scripts that work with BusyBox sh + stat (Alpine) and GNU
|
||||
// coreutils alike. The user-supplied relative path arrives as $1; we cd into
|
||||
// it before iterating, so user input never lands as an argv element to a
|
||||
// command that might interpret it as a flag.
|
||||
const LIST_SCRIPT = `set -e
|
||||
cd -- "$1" 2>/dev/null || { echo "cd: $1: No such file or directory" >&2; exit 1; }
|
||||
cd -- "$1" || exit 1
|
||||
${ROOT_GUARD}
|
||||
for entry in * .[!.]* ..?*; do
|
||||
[ -e "$entry" ] || [ -L "$entry" ] || continue
|
||||
if [ -L "$entry" ]; then t=l; link=$(readlink -- "$entry" 2>/dev/null || echo "")
|
||||
@@ -25,8 +39,15 @@ for entry in * .[!.]* ..?*; do
|
||||
printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$t" "$size" "$mtime" "$entry" "$link"
|
||||
done`;
|
||||
|
||||
// Contain the parent before statting the leaf: cd into the leaf's directory and
|
||||
// assert (via pwd -P) it is still inside the volume, so a symlinked path
|
||||
// component cannot make stat report on a file outside the mount.
|
||||
const STAT_SCRIPT = `set -e
|
||||
target="$1"
|
||||
p="$1"
|
||||
d=$(dirname -- "$p"); b=$(basename -- "$p")
|
||||
cd -- "$d" || exit 1
|
||||
${ROOT_GUARD}
|
||||
target="$b"
|
||||
[ -e "$target" ] || [ -L "$target" ] || { echo "cannot access $target" >&2; exit 1; }
|
||||
if [ -L "$target" ]; then t=l; link=$(readlink -- "$target" 2>/dev/null || echo "")
|
||||
elif [ -d "$target" ]; then t=d; link=""
|
||||
@@ -38,6 +59,62 @@ mtime=$(stat -c '%Y' -- "$target" 2>/dev/null || echo 0)
|
||||
name=$(basename -- "$target")
|
||||
printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$t" "$size" "$mtime" "$name" "$link"`;
|
||||
|
||||
// --- mutation + probe scripts (all contain the parent, then act on the leaf) ---
|
||||
|
||||
// $1 = relative path. New file → owned by the helper user (65534); existing file
|
||||
// → in-place truncate (`>` keeps the inode, so owner/mode are preserved). A
|
||||
// symlink leaf is refused so a write never follows a link out of the volume.
|
||||
const WRITE_SCRIPT = `set -e
|
||||
p="$1"
|
||||
d=$(dirname -- "$p"); f=$(basename -- "$p")
|
||||
cd -- "$d" || exit 1
|
||||
${ROOT_GUARD}
|
||||
[ -L "$f" ] && { echo "refusing to write through a symlink" >&2; exit 8; }
|
||||
[ -d "$f" ] && { echo "target is a directory" >&2; exit 9; }
|
||||
cat > "$f"`;
|
||||
|
||||
// $1 = relative path of the new directory; its parent must already exist.
|
||||
const MKDIR_SCRIPT = `set -e
|
||||
p="$1"
|
||||
d=$(dirname -- "$p"); f=$(basename -- "$p")
|
||||
cd -- "$d" || exit 1
|
||||
${ROOT_GUARD}
|
||||
mkdir -- "$f"`;
|
||||
|
||||
// $1 = relative path, $2 = "1" for recursive directory delete. Removing a symlink
|
||||
// leaf is allowed (rm unlinks the link itself, never its target).
|
||||
const DELETE_SCRIPT = `set -e
|
||||
p="$1"; recursive="$2"
|
||||
d=$(dirname -- "$p"); f=$(basename -- "$p")
|
||||
cd -- "$d" || exit 1
|
||||
${ROOT_GUARD}
|
||||
[ -e "$f" ] || [ -L "$f" ] || { echo "no such path" >&2; exit 1; }
|
||||
if [ -d "$f" ] && [ ! -L "$f" ]; then
|
||||
if [ "$recursive" = "1" ]; then rm -rf -- "$f"; else rmdir -- "$f" 2>/dev/null || { echo "Directory is not empty" >&2; exit 10; }; fi
|
||||
else
|
||||
rm -f -- "$f"
|
||||
fi`;
|
||||
|
||||
// $1 = from, $2 = to. Both parents are contained; the destination must not exist.
|
||||
const RENAME_SCRIPT = `set -e
|
||||
from="$1"; to="$2"
|
||||
fd=$(dirname -- "$from"); td=$(dirname -- "$to")
|
||||
( cd -- "$fd" 2>/dev/null && case "$(pwd -P)" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) exit 7 ;; esac ) || { echo "source escapes volume root" >&2; exit 7; }
|
||||
( cd -- "$td" 2>/dev/null && case "$(pwd -P)" in "${VOLUME_MOUNT}"|"${VOLUME_MOUNT}/"*) ;; *) exit 7 ;; esac ) || { echo "destination escapes volume root" >&2; exit 7; }
|
||||
{ [ -e "$to" ] || [ -L "$to" ]; } && { echo "destination exists" >&2; exit 11; }
|
||||
mv -- "$from" "$to"`;
|
||||
|
||||
// $1 = relative path. Prints directory|file|none. Used for upload-overwrite checks.
|
||||
const PATHKIND_SCRIPT = `set -e
|
||||
p="$1"
|
||||
d=$(dirname -- "$p"); f=$(basename -- "$p")
|
||||
cd -- "$d" || exit 2
|
||||
${ROOT_GUARD}
|
||||
if [ -d "$f" ] && [ ! -L "$f" ]; then echo directory
|
||||
elif [ -e "$f" ] || [ -L "$f" ]; then echo file
|
||||
else echo none
|
||||
fi`;
|
||||
|
||||
export interface VolumeEntry {
|
||||
name: string;
|
||||
type: 'file' | 'directory' | 'symlink' | 'other';
|
||||
@@ -54,10 +131,30 @@ export interface VolumeFileResult {
|
||||
truncated: boolean;
|
||||
size: number;
|
||||
mime: string;
|
||||
/** mtime in milliseconds (seconds resolution from the helper `stat`, ×1000). */
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
/** Raw bytes of a volume file for download, bounded to DOWNLOAD_MAX_BYTES. */
|
||||
export interface VolumeDownload {
|
||||
buffer: Buffer;
|
||||
size: number;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export type VolumeStat = VolumeEntry;
|
||||
|
||||
/**
|
||||
* Opaque optimistic-concurrency token for an editable volume file. Seconds-
|
||||
* resolution mtime alone collides for two edits within the same second, so the
|
||||
* size and a content hash are folded in to keep the guarantee close to the
|
||||
* millisecond-mtime fs path.
|
||||
*/
|
||||
export function makeHelperVersion(mtimeSeconds: number, size: number, content: Buffer): string {
|
||||
const hash = createHash('sha256').update(content).digest('hex').slice(0, 16);
|
||||
return `"v1:${mtimeSeconds}-${size}-${hash}"`;
|
||||
}
|
||||
|
||||
export class PathTraversalError extends Error {
|
||||
status = 400;
|
||||
constructor() { super('Path escapes volume root'); this.name = 'PathTraversalError'; }
|
||||
@@ -149,6 +246,7 @@ export class VolumeBrowserService {
|
||||
]);
|
||||
if (exitCode !== 0) {
|
||||
const msg = stderr.toString('utf-8').trim();
|
||||
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
|
||||
if (/No such file or directory|cannot access/i.test(msg)) throw new ExecError('Path not found', 404);
|
||||
throw new ExecError(`Stat failed: ${msg.substring(0, 200) || 'unknown error'}`);
|
||||
}
|
||||
@@ -181,12 +279,12 @@ export class VolumeBrowserService {
|
||||
if (meta.type === 'symlink') throw new ExecError('Refusing to follow symlink', 400);
|
||||
if (meta.type !== 'file') throw new ExecError('Not a regular file', 400);
|
||||
|
||||
// Read up to maxBytes+1 to detect truncation precisely. The path is
|
||||
// passed as $1 (an argv element, never concatenated) and read with
|
||||
// head -c -- "$1" so a leading-dash filename is never parsed as a flag.
|
||||
// Read up to maxBytes+1 to detect truncation precisely. The parent is
|
||||
// contained (cd + pwd -P) and the leaf symlink refused so the read can never
|
||||
// follow a link out of the volume.
|
||||
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [
|
||||
'sh', '-c',
|
||||
`head -c ${maxBytes + 1} -- "$1"`,
|
||||
`p="$1"; d=$(dirname -- "$p"); b=$(basename -- "$p"); cd -- "$d" || exit 1; ${ROOT_GUARD}; [ -L "$b" ] && { echo "refusing to follow symlink" >&2; exit 8; }; head -c ${maxBytes + 1} -- "$b"`,
|
||||
'sh', `./${safe}`,
|
||||
]);
|
||||
if (exitCode !== 0) {
|
||||
@@ -207,9 +305,151 @@ export class VolumeBrowserService {
|
||||
truncated,
|
||||
size: meta.size,
|
||||
mime,
|
||||
mtimeMs: meta.mtime * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file's full bytes for download, bounded to DOWNLOAD_MAX_BYTES so a
|
||||
* large file is rejected (413) rather than silently truncated. The parent is
|
||||
* contained and the leaf symlink refused, like readFile.
|
||||
*/
|
||||
async downloadFile(volumeName: string, relPath: string): Promise<VolumeDownload> {
|
||||
const safe = sanitizeRelPath(relPath);
|
||||
if (!safe) throw new ExecError('Cannot download volume root', 400);
|
||||
await this.assertVolumeExists(volumeName);
|
||||
await this.ensureHelperImage();
|
||||
|
||||
const meta = await this.stat(volumeName, safe);
|
||||
if (meta.type === 'symlink') throw new ExecError('Refusing to follow symlink', 400);
|
||||
if (meta.type !== 'file') throw new ExecError('Not a regular file', 400);
|
||||
if (meta.size > DOWNLOAD_MAX_BYTES) {
|
||||
throw new ExecError('File is too large to download from this volume', 413);
|
||||
}
|
||||
|
||||
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [
|
||||
'sh', '-c',
|
||||
`p="$1"; d=$(dirname -- "$p"); b=$(basename -- "$p"); cd -- "$d" || exit 1; ${ROOT_GUARD}; [ -L "$b" ] && { echo "refusing to follow symlink" >&2; exit 8; }; head -c ${DOWNLOAD_MAX_BYTES + 1} -- "$b"`,
|
||||
'sh', `./${safe}`,
|
||||
]);
|
||||
if (exitCode !== 0) {
|
||||
const msg = stderr.toString('utf-8').trim();
|
||||
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
|
||||
throw new ExecError(`Download failed: ${msg.substring(0, 200) || 'unknown error'}`);
|
||||
}
|
||||
if (stdout.length > DOWNLOAD_MAX_BYTES) {
|
||||
throw new ExecError('File is too large to download from this volume', 413);
|
||||
}
|
||||
return { buffer: stdout, size: stdout.length, filename: path.posix.basename(safe) };
|
||||
}
|
||||
|
||||
/** Probe whether a path is a directory, file, or absent (upload-overwrite check). */
|
||||
async pathKind(volumeName: string, relPath: string): Promise<'file' | 'directory' | null> {
|
||||
const safe = sanitizeRelPath(relPath);
|
||||
if (!safe) return 'directory';
|
||||
await this.assertVolumeExists(volumeName);
|
||||
await this.ensureHelperImage();
|
||||
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, ['sh', '-c', PATHKIND_SCRIPT, 'sh', `./${safe}`]);
|
||||
if (exitCode !== 0) {
|
||||
// A permission failure on the parent must not be reported as "absent"
|
||||
// (which would let an exclusive create proceed); surface it as 403. A
|
||||
// genuinely missing parent (ENOENT) means nothing exists at this path.
|
||||
if (/Permission denied/i.test(stderr.toString('utf-8'))) throw new ExecError('Permission denied', 403);
|
||||
return null;
|
||||
}
|
||||
const kind = stdout.toString('utf-8').trim();
|
||||
if (kind === 'directory') return 'directory';
|
||||
if (kind === 'file') return 'file';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `content` to a volume file. New files are created owned by the helper
|
||||
* user (65534); existing files are truncated in place so their owner/mode are
|
||||
* preserved. The write is intentionally NON-ATOMIC (a helper death mid-write
|
||||
* can leave a truncated file) because the helper cannot chown a renamed temp
|
||||
* back to the original owner; ownership preservation is the better default for
|
||||
* editing a service's config. Returns the new mtime/size for the version token.
|
||||
*/
|
||||
async writeFile(volumeName: string, relPath: string, content: Buffer): Promise<{ mtimeMs: number; size: number }> {
|
||||
const safe = sanitizeRelPath(relPath);
|
||||
if (!safe) throw new ExecError('Cannot write the volume root', 400);
|
||||
await this.assertVolumeExists(volumeName);
|
||||
await this.ensureHelperImage();
|
||||
|
||||
const { stderr, exitCode } = await this.runHelper(
|
||||
volumeName,
|
||||
['sh', '-c', WRITE_SCRIPT, 'sh', `./${safe}`],
|
||||
{ writable: true, stdin: content },
|
||||
);
|
||||
if (exitCode !== 0) {
|
||||
const msg = stderr.toString('utf-8').trim();
|
||||
if (/Permission denied/i.test(msg) || exitCode === 8) throw new ExecError('Permission denied', 403);
|
||||
if (exitCode === 9) throw new ExecError('Target is a directory', 400);
|
||||
throw new ExecError(`Write failed: ${msg.substring(0, 200) || 'unknown error'}`);
|
||||
}
|
||||
const meta = await this.stat(volumeName, safe);
|
||||
return { mtimeMs: meta.mtime * 1000, size: meta.size };
|
||||
}
|
||||
|
||||
/** Create a single directory; its parent must already exist. */
|
||||
async mkdir(volumeName: string, relPath: string): Promise<void> {
|
||||
const safe = sanitizeRelPath(relPath);
|
||||
if (!safe) throw new ExecError('Invalid directory path', 400);
|
||||
await this.assertVolumeExists(volumeName);
|
||||
await this.ensureHelperImage();
|
||||
const { stderr, exitCode } = await this.runHelper(
|
||||
volumeName,
|
||||
['sh', '-c', MKDIR_SCRIPT, 'sh', `./${safe}`],
|
||||
{ writable: true },
|
||||
);
|
||||
if (exitCode !== 0) {
|
||||
const msg = stderr.toString('utf-8').trim();
|
||||
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
|
||||
if (/File exists/i.test(msg)) throw new ExecError('A file or folder with that name already exists', 409);
|
||||
throw new ExecError(`Create folder failed: ${msg.substring(0, 200) || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a file or directory. Removing a symlink unlinks the link, not its target. */
|
||||
async deletePath(volumeName: string, relPath: string, recursive: boolean): Promise<void> {
|
||||
const safe = sanitizeRelPath(relPath);
|
||||
if (!safe) throw new ExecError('Cannot delete the volume root', 400);
|
||||
await this.assertVolumeExists(volumeName);
|
||||
await this.ensureHelperImage();
|
||||
const { stderr, exitCode } = await this.runHelper(
|
||||
volumeName,
|
||||
['sh', '-c', DELETE_SCRIPT, 'sh', `./${safe}`, recursive ? '1' : '0'],
|
||||
{ writable: true },
|
||||
);
|
||||
if (exitCode !== 0) {
|
||||
const msg = stderr.toString('utf-8').trim();
|
||||
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
|
||||
if (exitCode === 10 || /not empty/i.test(msg)) throw new ExecError('Directory is not empty', 409);
|
||||
throw new ExecError(`Delete failed: ${msg.substring(0, 200) || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rename/move a file or directory within the same volume. */
|
||||
async rename(volumeName: string, fromRel: string, toRel: string): Promise<void> {
|
||||
const from = sanitizeRelPath(fromRel);
|
||||
const to = sanitizeRelPath(toRel);
|
||||
if (!from || !to) throw new ExecError('Invalid rename path', 400);
|
||||
await this.assertVolumeExists(volumeName);
|
||||
await this.ensureHelperImage();
|
||||
const { stderr, exitCode } = await this.runHelper(
|
||||
volumeName,
|
||||
['sh', '-c', RENAME_SCRIPT, 'sh', `./${from}`, `./${to}`],
|
||||
{ writable: true },
|
||||
);
|
||||
if (exitCode !== 0) {
|
||||
const msg = stderr.toString('utf-8').trim();
|
||||
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
|
||||
if (exitCode === 11 || /exists/i.test(msg)) throw new ExecError('A file or folder with that name already exists', 409);
|
||||
throw new ExecError(`Rename failed: ${msg.substring(0, 200) || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- internals -----------------------------------------------------------
|
||||
|
||||
private async assertVolumeExists(volumeName: string): Promise<void> {
|
||||
@@ -250,17 +490,23 @@ export class VolumeBrowserService {
|
||||
}
|
||||
}
|
||||
|
||||
private async runHelper(volumeName: string, cmd: string[]): Promise<{ stdout: Buffer; stderr: Buffer; exitCode: number }> {
|
||||
private async runHelper(
|
||||
volumeName: string,
|
||||
cmd: string[],
|
||||
opts: { writable?: boolean; stdin?: Buffer } = {},
|
||||
): Promise<{ stdout: Buffer; stderr: Buffer; exitCode: number }> {
|
||||
const docker = DockerController.getInstance(this.nodeId).getDocker();
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
const stdoutStream = new Writable({ write(chunk, _enc, cb) { stdoutChunks.push(chunk); cb(); } });
|
||||
const stderrStream = new Writable({ write(chunk, _enc, cb) { stderrChunks.push(chunk); cb(); } });
|
||||
const wantStdin = opts.stdin !== undefined;
|
||||
|
||||
// Manual lifecycle (create -> attach -> start -> wait -> remove). Using
|
||||
// dockerode's docker.run() with AutoRemove races: Docker can delete the
|
||||
// container before run()'s internal wait() callback fires, surfacing as
|
||||
// a 404 "no such container" from docker-modem.
|
||||
// a 404 "no such container" from docker-modem. Only the target volume mount
|
||||
// becomes writable for mutations; every other hardening flag is unchanged.
|
||||
const container = await docker.createContainer({
|
||||
Image: HELPER_IMAGE,
|
||||
Cmd: cmd,
|
||||
@@ -269,6 +515,9 @@ export class VolumeBrowserService {
|
||||
WorkingDir: VOLUME_MOUNT,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
AttachStdin: wantStdin,
|
||||
OpenStdin: wantStdin,
|
||||
StdinOnce: wantStdin,
|
||||
HostConfig: {
|
||||
ReadonlyRootfs: true,
|
||||
NetworkMode: 'none',
|
||||
@@ -281,7 +530,7 @@ export class VolumeBrowserService {
|
||||
Type: 'volume',
|
||||
Source: volumeName,
|
||||
Target: VOLUME_MOUNT,
|
||||
ReadOnly: true,
|
||||
ReadOnly: !opts.writable,
|
||||
}],
|
||||
},
|
||||
});
|
||||
@@ -292,13 +541,19 @@ export class VolumeBrowserService {
|
||||
});
|
||||
|
||||
const runPromise = (async () => {
|
||||
const stream = await container.attach({ stream: true, stdout: true, stderr: true });
|
||||
const stream = await container.attach({ stream: true, stdin: wantStdin, stdout: true, stderr: true, hijack: wantStdin });
|
||||
const streamEnded = new Promise<void>((resolve) => {
|
||||
stream.once('end', () => resolve());
|
||||
stream.once('close', () => resolve());
|
||||
});
|
||||
docker.modem.demuxStream(stream, stdoutStream, stderrStream);
|
||||
await container.start();
|
||||
if (wantStdin && opts.stdin) {
|
||||
// Feed the file content to the container's stdin, then close it so the
|
||||
// helper's `cat > file` sees EOF and exits.
|
||||
stream.write(opts.stdin);
|
||||
stream.end();
|
||||
}
|
||||
const exitInfo = await container.wait();
|
||||
// Wait for the attach stream to finish flushing demuxed output.
|
||||
await streamEnded;
|
||||
|
||||
Reference in New Issue
Block a user