mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +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:
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* StackFileRootsService: discovers the safe, stack-scoped file "roots" the
|
||||
* Files & Volumes explorer can browse for a given stack. A root is either the
|
||||
* stack source directory, a declared bind-mount host directory, or a named
|
||||
* Docker volume. Discovery is the single server-side source of truth for which
|
||||
* paths a file operation may touch: the route always re-derives the allowed
|
||||
* roots and matches the client `rootId` against them, so a client can never
|
||||
* address a path the stack itself did not declare.
|
||||
*
|
||||
* Reuses the rendered effective model (`parseEffectiveModel`) and
|
||||
* `isDockerSocketMount` from the storage feature so it never re-implements mount
|
||||
* parsing. Bind browse-accessibility uses its own `probeBindRootAccess` (NOT the
|
||||
* portability-focused `probeHostPath`, which refuses to probe sources outside
|
||||
* the stack dir).
|
||||
*/
|
||||
import path from 'path';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { ComposeService } from './ComposeService';
|
||||
import DockerController from './DockerController';
|
||||
import { parseEffectiveModel, type EffectiveModel } from './preflight/effectiveModel';
|
||||
import { isDockerSocketMount } from './storage/types';
|
||||
import { isPathWithinBase, isValidStackName } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
|
||||
export interface RootMount {
|
||||
service: string;
|
||||
containerPath: string;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export interface StackFileRoot {
|
||||
/** Opaque, server-minted id. The resolved path/name lives in metadata, never in the id. */
|
||||
id: string;
|
||||
kind: 'stack-source' | 'bind' | 'volume';
|
||||
label: string;
|
||||
/** Absolute host path (bind), resolved Docker volume name (volume), or stack dir (stack-source). */
|
||||
hostPathOrName: string;
|
||||
/** Every service/containerPath/readOnly declaration that maps to this resolved source. */
|
||||
mounts: RootMount[];
|
||||
/** Aggregate: true only when EVERY declaration is read-only. */
|
||||
readonly: boolean;
|
||||
/** Bind: a stat-able directory reachable by Sencho. Volume: the helper can inspect it. */
|
||||
accessible: boolean;
|
||||
browsable: boolean;
|
||||
writable: boolean;
|
||||
/** fs roots only (POSIX chmod is unsupported on helper-backed volume roots). */
|
||||
chmodable: boolean;
|
||||
dangerous: boolean;
|
||||
/** Bind overlaps Sencho's managed compose base / a stack dir, so it is suppressed. */
|
||||
managedSourceOverlap: boolean;
|
||||
warning: string | null;
|
||||
backend: 'fs' | 'helper';
|
||||
}
|
||||
|
||||
export const STACK_SOURCE_ROOT_ID = 'stack-source';
|
||||
|
||||
/**
|
||||
* The stack-source root. `hostPathOrName` is informational (the gateway scopes
|
||||
* stack-source ops to the stack dir via FileSystemService, not this field), so
|
||||
* the route can build a synthetic one without resolving the compose base dir.
|
||||
*/
|
||||
export function stackSourceFileRoot(hostPathOrName = ''): StackFileRoot {
|
||||
return {
|
||||
id: STACK_SOURCE_ROOT_ID,
|
||||
kind: 'stack-source',
|
||||
label: 'Stack source',
|
||||
hostPathOrName,
|
||||
mounts: [],
|
||||
readonly: false,
|
||||
accessible: true,
|
||||
browsable: true,
|
||||
writable: true,
|
||||
chmodable: true,
|
||||
dangerous: false,
|
||||
managedSourceOverlap: false,
|
||||
warning: null,
|
||||
backend: 'fs',
|
||||
};
|
||||
}
|
||||
|
||||
const ROOTS_CACHE_TTL_MS = 15_000;
|
||||
|
||||
// Dangerous host directories: a bind equal to or under any of these grants
|
||||
// node-level access and is never browsable. The docker socket is caught
|
||||
// separately via isDockerSocketMount on the declared source.
|
||||
const DANGEROUS_ROOTS = ['/etc', '/proc', '/sys', '/dev', '/var/run', '/run'];
|
||||
|
||||
interface CacheEntry {
|
||||
roots: StackFileRoot[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// Module-level cache: getInstance(nodeId) returns a fresh service each call (like
|
||||
// FileSystemService), so the cache must outlive the instance. Keyed by node+stack.
|
||||
const rootsCache = new Map<string, CacheEntry>();
|
||||
|
||||
/**
|
||||
* Sencho's writable data directory (sencho.db, encryption.key, backups). Mirrors
|
||||
* the resolution DatabaseService / FileSystemService use, so a bind mount that
|
||||
* points at it is recognised as a managed-area overlap and never browsable.
|
||||
*/
|
||||
function resolveDataDir(): string {
|
||||
return path.resolve(process.env.DATA_DIR || path.join(process.cwd(), 'data'));
|
||||
}
|
||||
|
||||
/** A bind source equal to or under one of the dangerous roots (POSIX semantics). */
|
||||
export function isDangerousHostPath(p: string): boolean {
|
||||
const norm = p.replace(/\\/g, '/');
|
||||
if (norm === '/') return true;
|
||||
return DANGEROUS_ROOTS.some((d) => norm === d || norm.startsWith(`${d}/`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse-accessibility probe for a bind root, scoped to the compose base dir.
|
||||
* The realpath/stat run ONLY for a source that lexically resolves inside
|
||||
* `baseDir`: in the containerized deployment that is the only host area the
|
||||
* Sencho process can reach, so a source outside it is unreachable anyway and is
|
||||
* reported non-accessible without touching the filesystem. The containment is
|
||||
* an INLINE path.resolve + startsWith guard at each filesystem sink (the
|
||||
* realpath of a within-base symlink can still escape, so the resolved canonical
|
||||
* is re-checked before stat). Returns the canonical realpath so the route can
|
||||
* pass it to FileSystemService as the containment root.
|
||||
*/
|
||||
export async function probeBindRootAccess(
|
||||
absPath: string,
|
||||
baseDir: string,
|
||||
): Promise<{ canonical: string; accessible: boolean; isDir: boolean }> {
|
||||
const base = path.resolve(baseDir);
|
||||
const resolved = path.resolve(absPath);
|
||||
// Out-of-base sources keep their original path for the dangerous/overlap
|
||||
// classification the caller does, but are never statted.
|
||||
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
|
||||
return { canonical: absPath, accessible: false, isDir: false };
|
||||
}
|
||||
// A missing path (ENOENT) is the common, expected "not reachable" outcome and
|
||||
// is left silent; any other code (e.g. EACCES on a path that exists but Sencho
|
||||
// cannot read) is logged so an operator chasing "why can't I browse this bind"
|
||||
// has a trail, while the root still degrades gracefully to non-browsable.
|
||||
const logNonEnoent = (stage: string, err: unknown): void => {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT') {
|
||||
console.warn('[StackFileRoots] bind %s failed (%s):', stage, code ?? 'unknown', sanitizeForLog(resolved));
|
||||
}
|
||||
};
|
||||
let canonical: string;
|
||||
try {
|
||||
canonical = await fsPromises.realpath(resolved);
|
||||
} catch (err) {
|
||||
logNonEnoent('realpath', err);
|
||||
return { canonical: resolved, accessible: false, isDir: false };
|
||||
}
|
||||
// A within-base source can be a symlink whose target escapes the base; re-check
|
||||
// the resolved canonical inline before the stat sink.
|
||||
if (canonical !== base && !canonical.startsWith(base + path.sep)) {
|
||||
return { canonical, accessible: false, isDir: false };
|
||||
}
|
||||
try {
|
||||
const st = await fsPromises.stat(canonical);
|
||||
return { canonical, accessible: true, isDir: st.isDirectory() };
|
||||
} catch (err) {
|
||||
logNonEnoent('stat', err);
|
||||
return { canonical, accessible: false, isDir: false };
|
||||
}
|
||||
}
|
||||
|
||||
function shortHash(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function debugCount(event: string, stackName: string): void {
|
||||
if (isDebugEnabled()) {
|
||||
console.debug('[StackFileRoots:debug] %s for %s', event, sanitizeForLog(stackName));
|
||||
}
|
||||
}
|
||||
|
||||
export class StackFileRootsService {
|
||||
private nodeId: number;
|
||||
|
||||
private constructor(nodeId: number) {
|
||||
this.nodeId = nodeId;
|
||||
}
|
||||
|
||||
static getInstance(nodeId?: number): StackFileRootsService {
|
||||
return new StackFileRootsService(nodeId ?? NodeRegistry.getInstance().getDefaultNodeId());
|
||||
}
|
||||
|
||||
/** Drop the cached allowlist for a stack so a changed mount stops being addressable immediately. */
|
||||
static invalidate(nodeId: number, stackName: string): void {
|
||||
rootsCache.delete(`${nodeId}:${stackName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every cached allowlist for a node. Called on stack lifecycle changes
|
||||
* (create / delete / import / from-git) so a stack deleted and recreated under
|
||||
* the same name cannot serve the old stack's roots from the TTL cache.
|
||||
*/
|
||||
static invalidateNode(nodeId: number): void {
|
||||
const prefix = `${nodeId}:`;
|
||||
for (const key of rootsCache.keys()) {
|
||||
if (key.startsWith(prefix)) rootsCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(stackName: string): void {
|
||||
StackFileRootsService.invalidate(this.nodeId, stackName);
|
||||
}
|
||||
|
||||
private cacheKey(stackName: string): string {
|
||||
return `${this.nodeId}:${stackName}`;
|
||||
}
|
||||
|
||||
private stackSourceRoot(stackDir: string): StackFileRoot {
|
||||
return stackSourceFileRoot(stackDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the merged effective model on the owning node. Returns null on any
|
||||
* render/parse failure (so discovery degrades to stack-source only); never
|
||||
* throws and never surfaces raw stderr.
|
||||
*/
|
||||
private async renderModel(stackName: string): Promise<EffectiveModel | null> {
|
||||
try {
|
||||
const result = await ComposeService.getInstance(this.nodeId).renderConfig(stackName);
|
||||
if (result.rendered === null) return null;
|
||||
return parseEffectiveModel(JSON.parse(result.rendered), stackName);
|
||||
} catch (err) {
|
||||
console.warn('[StackFileRoots] Model render failed for %s:',
|
||||
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown')));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async listRoots(stackName: string, opts: { fresh?: boolean } = {}): Promise<StackFileRoot[]> {
|
||||
if (!isValidStackName(stackName)) {
|
||||
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
|
||||
}
|
||||
|
||||
const key = this.cacheKey(stackName);
|
||||
if (!opts.fresh) {
|
||||
const cached = rootsCache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
debugCount('cache hit', stackName);
|
||||
return cached.roots;
|
||||
}
|
||||
}
|
||||
debugCount(opts.fresh ? 'fresh' : 'cache miss', stackName);
|
||||
|
||||
const baseDir = FileSystemService.getInstance(this.nodeId).getBaseDir();
|
||||
const stackDir = path.join(baseDir, stackName);
|
||||
const roots: StackFileRoot[] = [this.stackSourceRoot(stackDir)];
|
||||
|
||||
const model = await this.renderModel(stackName);
|
||||
if (!model) {
|
||||
// Render failure: only the stack-source root survives (never depends on a
|
||||
// render). Cache the stack-source-only result, never a stale allowlist.
|
||||
debugCount('render failure', stackName);
|
||||
rootsCache.set(key, { roots, expiresAt: Date.now() + ROOTS_CACHE_TTL_MS });
|
||||
return roots;
|
||||
}
|
||||
|
||||
roots.push(...(await this.discoverVolumeRoots(model, baseDir, stackDir)));
|
||||
rootsCache.set(key, { roots, expiresAt: Date.now() + ROOTS_CACHE_TTL_MS });
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a client-supplied rootId to a server-derived root. Throws INVALID_ROOT
|
||||
* for an unknown/forged id. Writes pass `fresh: true` to bypass the cache, so a
|
||||
* removed mount can never be written through a stale allowlist.
|
||||
*/
|
||||
async resolveRoot(stackName: string, rootId: string, opts: { fresh?: boolean } = {}): Promise<StackFileRoot> {
|
||||
if (!isValidStackName(stackName)) {
|
||||
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
|
||||
}
|
||||
// The stack-source root never depends on a compose render, so resolve it
|
||||
// directly. This keeps plain stack-source file ops (the common case, and all
|
||||
// existing API clients that send no rootId) off the docker-compose render
|
||||
// path entirely.
|
||||
if (rootId === STACK_SOURCE_ROOT_ID) {
|
||||
const baseDir = FileSystemService.getInstance(this.nodeId).getBaseDir();
|
||||
return this.stackSourceRoot(path.join(baseDir, stackName));
|
||||
}
|
||||
const roots = await this.listRoots(stackName, opts);
|
||||
const root = roots.find((r) => r.id === rootId);
|
||||
if (!root) {
|
||||
throw Object.assign(new Error('Unknown file root'), { code: 'INVALID_ROOT' });
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private async discoverVolumeRoots(
|
||||
model: EffectiveModel,
|
||||
baseDir: string,
|
||||
stackDir: string,
|
||||
): Promise<StackFileRoot[]> {
|
||||
interface BindGroup {
|
||||
canonical: string;
|
||||
accessible: boolean;
|
||||
isDir: boolean;
|
||||
dockerSock: boolean;
|
||||
mounts: RootMount[];
|
||||
}
|
||||
const probeByRaw = new Map<string, { canonical: string; accessible: boolean; isDir: boolean }>();
|
||||
const bindByCanonical = new Map<string, BindGroup>();
|
||||
const volByName = new Map<string, { name: string; mounts: RootMount[] }>();
|
||||
|
||||
for (const svc of model.services) {
|
||||
for (const m of svc.storageMounts ?? []) {
|
||||
const mount: RootMount = { service: svc.name, containerPath: m.target, readOnly: m.readOnly };
|
||||
if (m.type === 'bind' && m.source) {
|
||||
const rawAbs = path.isAbsolute(m.source) ? m.source : path.resolve(stackDir, m.source);
|
||||
let probe = probeByRaw.get(rawAbs);
|
||||
if (!probe) {
|
||||
probe = await probeBindRootAccess(rawAbs, baseDir);
|
||||
probeByRaw.set(rawAbs, probe);
|
||||
}
|
||||
let group = bindByCanonical.get(probe.canonical);
|
||||
if (!group) {
|
||||
group = {
|
||||
canonical: probe.canonical,
|
||||
accessible: probe.accessible,
|
||||
isDir: probe.isDir,
|
||||
dockerSock: false,
|
||||
mounts: [],
|
||||
};
|
||||
bindByCanonical.set(probe.canonical, group);
|
||||
}
|
||||
group.mounts.push(mount);
|
||||
if (isDockerSocketMount({ source: m.source, target: m.target })) group.dockerSock = true;
|
||||
} else if (m.type === 'named' && m.source) {
|
||||
const resolvedName = model.volumes[m.source]?.name ?? m.source;
|
||||
let group = volByName.get(resolvedName);
|
||||
if (!group) {
|
||||
group = { name: resolvedName, mounts: [] };
|
||||
volByName.set(resolvedName, group);
|
||||
}
|
||||
group.mounts.push(mount);
|
||||
}
|
||||
// anonymous / tmpfs mounts have no stable browse target and are skipped.
|
||||
}
|
||||
}
|
||||
|
||||
const roots: StackFileRoot[] = [];
|
||||
for (const group of bindByCanonical.values()) {
|
||||
const root = this.buildBindRoot(group, baseDir, stackDir);
|
||||
if (root) roots.push(root);
|
||||
}
|
||||
for (const group of volByName.values()) {
|
||||
roots.push(await this.buildVolumeRoot(group));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
private buildBindRoot(
|
||||
group: { canonical: string; accessible: boolean; isDir: boolean; dockerSock: boolean; mounts: RootMount[] },
|
||||
baseDir: string,
|
||||
stackDir: string,
|
||||
): StackFileRoot | null {
|
||||
const { canonical } = group;
|
||||
|
||||
// A bind equal to the stack dir is already served (with protected-file
|
||||
// enforcement) by the stack-source root; do not expose a second, unprotected
|
||||
// editable root for it.
|
||||
if (canonical === stackDir) return null;
|
||||
|
||||
const inStack = isPathWithinBase(canonical, stackDir); // strictly within (equal handled above)
|
||||
// A bind that overlaps Sencho's own managed areas (the compose base dir, a
|
||||
// sibling stack, or the data dir that holds sencho.db / encryption.key) must
|
||||
// never become a browsable/editable root. Compare in both directions so a
|
||||
// mount equal to, inside, or an ancestor of a managed dir is caught.
|
||||
const dataDir = resolveDataDir();
|
||||
const overlapsManaged = (dir: string): boolean => isPathWithinBase(canonical, dir) || isPathWithinBase(dir, canonical);
|
||||
const overlap = !inStack && (overlapsManaged(baseDir) || overlapsManaged(dataDir));
|
||||
const dangerous = isDangerousHostPath(canonical) || group.dockerSock;
|
||||
const readonly = group.mounts.every((m) => m.readOnly);
|
||||
const isFile = group.accessible && !group.isDir;
|
||||
|
||||
let warning: string | null = null;
|
||||
if (overlap) {
|
||||
warning = "This mount overlaps Sencho's managed stack area. Browse the owning stack's source instead.";
|
||||
} else if (dangerous) {
|
||||
warning = 'This mount targets a protected host path and cannot be browsed.';
|
||||
} else if (!group.accessible) {
|
||||
warning = 'Sencho cannot access this host path. Bind it into the Sencho container to browse it.';
|
||||
} else if (isFile) {
|
||||
warning = 'This bind mount targets a single file, not a directory.';
|
||||
}
|
||||
|
||||
const browsable = group.accessible && group.isDir && !dangerous && !overlap;
|
||||
const writable = browsable && !readonly;
|
||||
const label = group.mounts[0]?.containerPath || path.basename(canonical) || canonical;
|
||||
|
||||
return {
|
||||
id: `bind:${shortHash(canonical)}`,
|
||||
kind: 'bind',
|
||||
label,
|
||||
hostPathOrName: canonical,
|
||||
mounts: group.mounts,
|
||||
readonly,
|
||||
accessible: group.accessible,
|
||||
browsable,
|
||||
writable,
|
||||
chmodable: browsable,
|
||||
dangerous,
|
||||
managedSourceOverlap: overlap,
|
||||
warning,
|
||||
backend: 'fs',
|
||||
};
|
||||
}
|
||||
|
||||
private async buildVolumeRoot(group: { name: string; mounts: RootMount[] }): Promise<StackFileRoot> {
|
||||
let accessible = false;
|
||||
let warning: string | null = null;
|
||||
try {
|
||||
await DockerController.getInstance(this.nodeId).getDocker().getVolume(group.name).inspect();
|
||||
accessible = true;
|
||||
} catch (err) {
|
||||
// Distinguish a genuine 404 (volume absent) from a transient Docker failure
|
||||
// (daemon unreachable, proxy hop down) so the warning is honest and a
|
||||
// non-404 leaves a server-side trail rather than silently reading as "gone".
|
||||
const e = err as { statusCode?: number; message?: string };
|
||||
const notFound = e.statusCode === 404 || /no such volume/i.test(e.message ?? '');
|
||||
if (!notFound) {
|
||||
console.warn('[StackFileRoots] volume inspect failed for %s:',
|
||||
sanitizeForLog(group.name), sanitizeForLog(getErrorMessage(err, 'unknown')));
|
||||
}
|
||||
warning = notFound
|
||||
? 'Sencho could not resolve this named volume on the owning node.'
|
||||
: 'Sencho could not reach Docker to resolve this named volume; it may be temporarily unavailable.';
|
||||
}
|
||||
const readonly = group.mounts.every((m) => m.readOnly);
|
||||
const browsable = accessible;
|
||||
return {
|
||||
id: `volume:${shortHash(group.name)}`,
|
||||
kind: 'volume',
|
||||
label: group.name,
|
||||
hostPathOrName: group.name,
|
||||
mounts: group.mounts,
|
||||
readonly,
|
||||
accessible,
|
||||
browsable,
|
||||
writable: browsable && !readonly,
|
||||
chmodable: false,
|
||||
dangerous: false,
|
||||
managedSourceOverlap: false,
|
||||
warning,
|
||||
backend: 'helper',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user