初始提交:DSH 多租户平台(dshs)

This commit is contained in:
admin committed 2026-09-13 16:18:10 +08:00
commit 43976fea6a
167 files changed
+24456

No files matched your search

+167
View File
@@ -0,0 +1,167 @@
/**
* HTTP {@link UserFs}: every file operation is delegated to the user's own file
* sidecar (docs/k8s.md §4.10), because the control plane runs as uid 65532 with
* no users volume and could not touch a `0700` user directory even if it did.
*
* The sidecar is addressed through its per-user Headless Service. That DNS A
* record has a ~30s TTL, so a Pod that was just rebuilt can still resolve to
* its old IP — connection-level failures therefore drop the keep-alive pool and
* retry once, mirroring what the DSH proxy does (docs/k8s.md §5.4).
* @module dshs/fs/k8s-user-fs
*/
import { Agent, request as httpRequest } from 'node:http'
import type { Endpoint } from '../supervisor/spawner.js'
import { POSIX, PathEscapeError, resolveWithinRoot } from '../web/middleware/fs-guard.js'
import type { PluginInfo } from './plugins.js'
import { isUserFsErrorCode, UserFsError, type UserFs } from './user-fs.js'
import { HOME_DIR, USERS_DIR, WORKSPACE_DIR, type FsEntry } from './workspace.js'
/** Data root inside every per-user Pod (mirrors `k8s-spawner`'s POD_DATA_ROOT). */
const POD_DATA_ROOT = '/var/lib/dshs'
/** Errors that mean "the connection never got anywhere" — worth one retry
* against a freshly resolved address. */
const RETRYABLE = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ETIMEDOUT'])
/** Ensure the user's file sidecar exists and is ready; supplied by the spawner. */
export type EnsureFileService = (userId: string) => Promise<void>
interface Reply {
status: number
body: unknown
}
export class K8sUserFs implements UserFs {
private agent = new Agent({ keepAlive: true, maxSockets: 16 })
/**
* @param ensureFileService - brings the sidecar up before the first call.
* @param endpointFor - the sidecar's address; the k8s backend supplies the
* per-user Headless Service DNS, tests supply a loopback bind.
*/
constructor(
private readonly ensureFileService: EnsureFileService,
private readonly endpointFor: (userId: string) => Endpoint,
) {}
async initUserRoot(userId: string): Promise<void> {
// Creating the sidecar Pod *is* the initialization: its init container
// builds `<pvc>/<userId>/{ws,home}` as the user's own uid (docs/k8s.md §4.9).
await this.ensureFileService(userId)
await this.call(userId, 'POST', '/fs/init')
}
resolvePath(userId: string, relPath: string): string {
const root = `${POD_DATA_ROOT}/${USERS_DIR}/${userId}/${WORKSPACE_DIR}`
try {
return resolveWithinRoot(root, relPath, POSIX)
} catch (err) {
if (err instanceof PathEscapeError) throw new UserFsError('bad_path')
throw err
}
}
/** The user's DSH state directory inside their Pod. */
homePath(userId: string): string {
return `${POD_DATA_ROOT}/${USERS_DIR}/${userId}/${HOME_DIR}`
}
async listDir(userId: string, relPath: string): Promise<FsEntry[]> {
const body = await this.call(userId, 'GET', `/fs/tree?path=${encodeURIComponent(relPath)}`)
return (body as { entries: FsEntry[] }).entries
}
async mkdir(userId: string, relPath: string): Promise<void> {
await this.call(userId, 'POST', '/fs/mkdir', { path: relPath })
}
async createEntry(userId: string, relPath: string, name: string, type: 'file' | 'dir'): Promise<string> {
const body = await this.call(userId, 'POST', '/fs/create', { path: relPath, name, type })
return (body as { name: string }).name
}
async upload(userId: string, relPath: string, name: string, data: Buffer): Promise<string> {
const body = await this.call(userId, 'POST', '/fs/upload', {
path: relPath,
name,
data: data.toString('base64'),
})
return (body as { name: string }).name
}
/** 档案 56:k8s 路径未验证 —— sidecar 尚无 read 端点,明确报 `unsupported` 而非静默失败。 */
async readFile(): Promise<{ name: string; data: Buffer }> {
throw new UserFsError('unsupported')
}
async isDirectory(userId: string, relPath: string): Promise<boolean> {
const body = await this.call(userId, 'GET', `/fs/stat?path=${encodeURIComponent(relPath)}`)
return (body as { isDirectory: boolean }).isDirectory
}
async listInstalledPlugins(userId: string): Promise<PluginInfo[]> {
const body = await this.call(userId, 'GET', '/fs/plugins')
return (body as { plugins: PluginInfo[] }).plugins
}
async writeHandoff(userId: string, content: string): Promise<void> {
await this.call(userId, 'POST', '/fs/handoff', { content })
}
/** One sidecar call: ensure the Pod, send, retry once on a dead connection,
* then translate a non-2xx `{error}` back into a {@link UserFsError}. */
private async call(userId: string, method: string, path: string, payload?: unknown): Promise<unknown> {
await this.ensureFileService(userId)
let reply: Reply
try {
reply = await this.send(userId, method, path, payload)
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === undefined || !RETRYABLE.has(code)) throw err
// The keep-alive pool may hold sockets to a Pod IP that no longer exists;
// drop them so the retry re-resolves the Headless Service.
this.agent.destroy()
this.agent = new Agent({ keepAlive: true, maxSockets: 16 })
reply = await this.send(userId, method, path, payload)
}
if (reply.status >= 200 && reply.status < 300) return reply.body
const error = (reply.body as { error?: unknown }).error
if (typeof error === 'string' && isUserFsErrorCode(error)) throw new UserFsError(error)
throw new Error(`file sidecar for ${userId} returned ${reply.status}`)
}
private send(userId: string, method: string, path: string, payload?: unknown): Promise<Reply> {
const body = payload === undefined ? undefined : JSON.stringify(payload)
const endpoint = this.endpointFor(userId)
return new Promise<Reply>((resolve, reject) => {
const req = httpRequest(
{
host: endpoint.host,
port: endpoint.port,
path,
method,
agent: this.agent,
headers: body === undefined
? {}
: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
},
(res) => {
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => chunks.push(chunk))
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8')
try {
resolve({ status: res.statusCode ?? 502, body: text === '' ? {} : JSON.parse(text) })
} catch {
reject(new Error(`file sidecar for ${userId} returned non-JSON (${res.statusCode})`))
}
})
},
)
req.on('error', reject)
if (body !== undefined) req.write(body)
req.end()
})
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Direct-filesystem {@link UserFs}: the control plane owns the users volume and
* touches it in-process. This is the `deployMode=local` implementation, and it
* is also what the per-user file sidecar runs behind its HTTP surface — the
* sidecar is just a `LocalUserFs` pinned to one user's root.
* @module dshs/fs/local-user-fs
*/
import { chownSync, chmodSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { stat } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { PathEscapeError, resolveWithinRoot, safeFilename } from '../web/middleware/fs-guard.js'
import { listInstalledPlugins, type PluginInfo } from './plugins.js'
import { UserFsError, type UserFs } from './user-fs.js'
import { ensureDir, handoffPath, homeRoot, listDir, rejectSymlinkEscape, workspaceRoot, type FsEntry } from './workspace.js'
/** Resolve a user id to that user's data root (`<dataRoot>/users/<id>`, or a
* fixed directory when the sidecar serves exactly one user). */
export type UserRootResolver = (userId: string) => string
/** Map a Node errno onto the wire code the desktop already handles. */
function fsError(err: unknown, onMissing: 'not_found' | 'parent_missing'): never {
const code = (err as NodeJS.ErrnoException).code
if (code === 'EEXIST') throw new UserFsError('exists')
if (code === 'ENOENT' || code === 'ENOTDIR') throw new UserFsError(onMissing)
throw err
}
export class LocalUserFs implements UserFs {
constructor(private readonly rootFor: UserRootResolver) {}
async initUserRoot(userId: string, uid?: number): Promise<void> {
const root = this.rootFor(userId)
ensureDir(homeRoot(root))
ensureDir(workspaceRoot(root))
// The roots were created by the (possibly root) control plane; the DSH child
// runs as the user's own uid and must be able to traverse + write them.
// chown the user root + home/ws so the child can mkdir profiles/, etc.
if (uid !== undefined && typeof process.getuid === 'function' && process.getuid() === 0) {
chownSync(root, uid, uid)
chownSync(homeRoot(root), uid, uid)
chownSync(workspaceRoot(root), uid, uid)
// 0700 + sticky-ish owner only; keep group/other off.
chmodSync(homeRoot(root), 0o700)
chmodSync(workspaceRoot(root), 0o700)
}
}
resolvePath(userId: string, relPath: string): string {
return this.resolve(userId, relPath)
}
async listDir(userId: string, relPath: string): Promise<FsEntry[]> {
const abs = this.resolve(userId, relPath)
await this.assertNoLinks(userId, abs)
try {
return await listDir(abs)
} catch (err) {
fsError(err, 'not_found')
}
}
async mkdir(userId: string, relPath: string): Promise<void> {
const abs = this.resolve(userId, relPath)
await this.assertNoLinks(userId, abs)
try {
await mkdir(abs)
} catch (err) {
fsError(err, 'parent_missing')
}
}
async createEntry(userId: string, relPath: string, name: string, type: 'file' | 'dir'): Promise<string> {
const dirAbs = this.resolve(userId, relPath)
const filename = this.sanitize(name)
const target = join(dirAbs, filename)
await this.assertNoLinks(userId, target)
try {
if (type === 'dir') await mkdir(target)
else await writeFile(target, '')
} catch (err) {
fsError(err, 'parent_missing')
}
return filename
}
async upload(userId: string, relPath: string, name: string, data: Buffer): Promise<string> {
const dirAbs = this.resolve(userId, relPath)
const filename = this.sanitize(name)
const target = join(dirAbs, filename)
await this.assertNoLinks(userId, target)
try {
await writeFile(target, data)
} catch (err) {
fsError(err, 'parent_missing')
}
return filename
}
/** 档案 56:读取工作区内的**文件**(门户/实例页「我的文件」下载用)。 */
async readFile(
userId: string,
relPath: string,
maxBytes = 32 * 1024 * 1024,
): Promise<{ name: string; data: Buffer }> {
const abs = this.resolve(userId, relPath)
await this.assertNoLinks(userId, abs)
let st
try {
st = await stat(abs)
} catch (err) {
fsError(err, 'not_found')
}
if (st.isDirectory()) throw new UserFsError('not_a_file')
if (st.size > maxBytes) throw new UserFsError('too_large')
try {
return { name: basename(abs), data: await readFile(abs) }
} catch (err) {
fsError(err, 'not_found')
}
}
async isDirectory(userId: string, relPath: string): Promise<boolean> {
const abs = this.resolve(userId, relPath)
await this.assertNoLinks(userId, abs)
try {
return (await stat(abs)).isDirectory()
} catch (err) {
fsError(err, 'not_found')
}
}
async listInstalledPlugins(userId: string): Promise<PluginInfo[]> {
return listInstalledPlugins(this.rootFor(userId))
}
async writeHandoff(userId: string, content: string): Promise<void> {
const root = this.rootFor(userId)
ensureDir(root)
await writeFile(handoffPath(root), content)
}
/** Resolve a workspace-relative path, self-healing the root the way the
* pre-seam routes did (every one of them called `ensureWorkspaceRoot` first). */
private resolve(userId: string, relPath: string): string {
const ws = workspaceRoot(this.rootFor(userId))
ensureDir(ws)
try {
return resolveWithinRoot(ws, relPath)
} catch (err) {
if (err instanceof PathEscapeError) throw new UserFsError('bad_path')
throw err
}
}
/** The lexical guard above is prefix-only and blind to links planted inside
* the workspace, so every operation re-walks its final absolute path (the
* write target for upload/createEntry) and rejects any symlink component —
* `writeFile`/`mkdir` would otherwise follow it outside the root with this
* process's privileges. Surfaces as the usual `bad_path` wire code. */
private async assertNoLinks(userId: string, abs: string): Promise<void> {
try {
await rejectSymlinkEscape(workspaceRoot(this.rootFor(userId)), abs)
} catch (err) {
if (err instanceof PathEscapeError) throw new UserFsError('bad_path')
throw err
}
}
private sanitize(name: string): string {
try {
return safeFilename(name)
} catch (err) {
if (err instanceof PathEscapeError) throw new UserFsError('bad_name')
throw err
}
}
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Per-user plugin discovery: read the resident DSH profile's `dsh.profile.bundles`
* and surface user-installed bundles, filtering out installation-owned
* `@deepseek-ai/*` bundles. Name/description come from each bundle's own
* package.json.
* @module dshs/fs/plugins
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { homeRoot } from './workspace.js'
/** A plugin the user may enable per folder (`id` doubles as the package name). */
export interface PluginInfo {
id: string
name: string
description: string
}
/** Profile name the resident main DSH is launched with (see supervisor). */
export const MAIN_PROFILE = 'web'
/** Bundles under this scope come from the dsh installation, not the user. */
const INSTALLATION_SCOPE = '@deepseek-ai/'
/** Absolute profile directory within a user root (`<root>/home/profiles/web`). */
function profileDir(root: string): string {
return join(homeRoot(root), 'profiles', MAIN_PROFILE)
}
/** Read a package.json `description`, tolerating a missing/empty field or file. */
function readDescription(manifestPath: string): string {
try {
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) as { description?: unknown }
return typeof parsed.description === 'string' ? parsed.description : ''
} catch {
return ''
}
}
/**
* List a user's installed plugins from their profile's `dsh.profile.bundles`,
* minus installation-owned bundles. Returns `[]` when the profile (or a listed
* package) has not been installed yet. Order follows the bundle layer order.
* @param root - the user's data root (see {@link userRoot}).
*/
export function listInstalledPlugins(root: string): PluginInfo[] {
const dir = profileDir(root)
let manifest: { dsh?: { profile?: { bundles?: string[] } } }
try {
manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
} catch {
return []
}
const bundles = manifest.dsh?.profile?.bundles ?? []
const plugins: PluginInfo[] = []
for (const packageName of bundles) {
if (packageName.startsWith(INSTALLATION_SCOPE)) continue
plugins.push({
id: packageName,
name: packageName,
description: readDescription(join(dir, 'node_modules', packageName, 'package.json')),
})
}
return plugins
}
+37
View File
@@ -0,0 +1,37 @@
/**
* {@link UserFs} factory. Picks the implementation from `deployMode`, the same
* way {@link createDbAdapter} picks a DB backend and `buildServer` picks a
* {@link Spawner}.
* @module dshs/fs/provider
*/
import type { ServerConfig } from '../config.js'
import type { Endpoint } from '../supervisor/spawner.js'
import { FILE_SERVICE_PORT } from '../web/file-service.js'
import { K8sUserFs, type EnsureFileService } from './k8s-user-fs.js'
import { LocalUserFs } from './local-user-fs.js'
import type { UserFs } from './user-fs.js'
import { userRoot } from './workspace.js'
/** Headless Service fronting a user's file sidecar (docs/k8s.md §4.10). */
export function fileServiceName(userId: string): string {
return `dsh-files-${userId}`
}
/** In-cluster address of a user's file sidecar. */
export function fileServiceEndpoint(namespace: string, userId: string): Endpoint {
return { host: `${fileServiceName(userId)}.${namespace}.svc.cluster.local`, port: FILE_SERVICE_PORT }
}
/**
* Build the configured per-user filesystem.
*
* `local` touches the users volume in-process. `k8s` delegates to each user's
* file sidecar, which the spawner brings up on demand via `ensureFileService`.
*/
export function createUserFs(config: ServerConfig, ensureFileService?: EnsureFileService): UserFs {
if (config.deployMode === 'k8s' && ensureFileService !== undefined) {
return new K8sUserFs(ensureFileService, (userId) => fileServiceEndpoint(config.k8sNamespace, userId))
}
return new LocalUserFs((userId) => userRoot(config.dataRoot, userId))
}
+91
View File
@@ -0,0 +1,91 @@
/**
* The per-user filesystem seam (docs/k8s.md §4.10 / §6.0-1).
*
* Under `local` the control plane owns the users volume and touches it directly
* ({@link LocalUserFs}). Under `k8s` it must not: it runs as uid 65532 while
* each user's directory is `0700` owned by that user's uid, so every file
* operation is delegated over HTTP to a per-user file sidecar
* ({@link K8sUserFs}). Routes depend only on this interface.
*
* All paths crossing this interface are **workspace-relative**; each
* implementation resolves them against its own root via `resolveWithinRoot`.
* @module dshs/fs/user-fs
*/
import type { PluginInfo } from './plugins.js'
import type { FsEntry } from './workspace.js'
/** Wire-level failure codes. These are the exact `{error}` values the desktop
* UI already switches on, so they survive the control-plane ↔ sidecar hop. */
export type UserFsErrorCode =
| 'bad_path'
| 'bad_name'
| 'not_found'
| 'exists'
| 'parent_missing'
| 'not_a_folder'
// 档案 56:下载/查看文件
| 'not_a_file'
| 'too_large'
// 该实现不支持(如 k8s sidecar 尚无 read 端点,档案 19 §C8 未验证路径)
| 'unsupported'
/** HTTP status each code maps to (unchanged from the pre-seam routes). */
const STATUS: Record<UserFsErrorCode, number> = {
bad_path: 400,
bad_name: 400,
not_found: 404,
exists: 409,
parent_missing: 404,
not_a_folder: 400,
not_a_file: 400,
too_large: 413,
unsupported: 501,
}
/**
* A filesystem failure already reduced to its wire form. Routes rethrow it as
* `reply.code(err.status).send({ error: err.code })` without inspecting errno,
* which is what lets the k8s implementation rebuild it from a sidecar response.
*/
export class UserFsError extends Error {
readonly status: number
constructor(readonly code: UserFsErrorCode) {
super(code)
this.name = 'UserFsError'
this.status = STATUS[code]
}
}
/** True when `code` is one this seam knows how to represent. */
export function isUserFsErrorCode(code: string): code is UserFsErrorCode {
return code in STATUS
}
/** Per-user filesystem operations, as the route layer needs them. */
export interface UserFs {
/** Create the user's home/workspace roots (`0700`). Idempotent.
* `uid` (when provided) makes local mode chown the roots to the user's Linux
* uid — the DSH child runs as that uid and would otherwise hit EACCES writing
* `home/` (directories are created by the root control plane). */
initUserRoot(userId: string, uid?: number): Promise<void>
/** Absolute path of `relPath` **as the user's DSH process sees it** (pure path
* math — the in-Pod mount path under k8s, the host path under local). */
resolvePath(userId: string, relPath: string): string
listDir(userId: string, relPath: string): Promise<FsEntry[]>
mkdir(userId: string, relPath: string): Promise<void>
/** Create a file or directory under `relPath`; returns the sanitized name. */
createEntry(userId: string, relPath: string, name: string, type: 'file' | 'dir'): Promise<string>
/** Write `data` as `name` under `relPath`; returns the sanitized name. */
upload(userId: string, relPath: string, name: string, data: Buffer): Promise<string>
/** Whether `relPath` is a directory; throws `not_found` when absent. */
isDirectory(userId: string, relPath: string): Promise<boolean>
/** Read a workspace-relative **file** (档案 56:供门户/实例页「我的文件」下载)。
* 目录 → `not_a_file`;超过 `maxBytes` → `too_large`。
* 注:k8s 实现目前抛 `unsupported`(sidecar 尚无 read 端点,属档案 19 §C8 未验证路径)。 */
readFile(userId: string, relPath: string, maxBytes?: number): Promise<{ name: string; data: Buffer }>
listInstalledPlugins(userId: string): Promise<PluginInfo[]>
/** Write the post-restart command handoff the watchdog reads. */
writeHandoff(userId: string, content: string): Promise<void>
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Per-user filesystem layout + low-level helpers. This module is the single
* place that knows the `users/<id>/{home,ws}` shape — the control plane, the
* local spawner, the k8s Pod spec, and the file sidecar all derive their paths
* from here so the four never drift apart.
*
* Isolation is enforced by the caller (see fs-guard).
* @module dshs/fs/workspace
*/
import { mkdirSync } from 'node:fs'
import { lstat, readdir, stat } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
import { PathEscapeError } from '../web/middleware/fs-guard.js'
// Layout segment names. Exported so the k8s Pod spec can build the same layout
// with POSIX separators (`join` would emit backslashes when the control plane
// is developed/tested on Windows) without duplicating the literals.
export const USERS_DIR = 'users'
export const WORKSPACE_DIR = 'ws'
export const HOME_DIR = 'home'
export const HANDOFF_FILE = 'handoff.json'
/** A user's own data root (`<dataRoot>/users/<id>`). */
export function userRoot(dataRoot: string, userId: string): string {
return join(dataRoot, USERS_DIR, userId)
}
/** The workspace (user-visible files) within a user root. */
export function workspaceRoot(root: string): string {
return join(root, WORKSPACE_DIR)
}
/** DSH's own state (profiles/sessions/credentials) within a user root. */
export function homeRoot(root: string): string {
return join(root, HOME_DIR)
}
/** The watchdog command handoff within a user root. */
export function handoffPath(root: string): string {
return join(root, HANDOFF_FILE)
}
/** Create a directory (0700) if it does not exist. */
export function ensureDir(path: string): void {
mkdirSync(path, { recursive: true, mode: 0o700 })
}
/**
* Reject `abs` when any path component between `root` and `abs` (inclusive) is
* a symbolic link. The lexical guard (`resolveWithinRoot`) cannot see links
* planted INSIDE the workspace: an upload through `ws/link -> /etc` passes the
* prefix check and lands outside the user's root with the caller's privileges.
* The desktop surface never needs symlinks, so they are forbidden here; the
* user's DSH process keeps its normal kernel view of links.
*
* Only components strictly below `root` are inspected — the data root itself
* may legitimately sit behind a link. A missing (or not-a-directory) component
* ends the walk: nothing can exist below it, and the caller's own open/stat
* will surface that as its usual errno.
*/
export async function rejectSymlinkEscape(root: string, abs: string): Promise<void> {
const rel = relative(root, abs)
if (rel === '') return // abs IS the root
if (rel.startsWith('..')) throw new PathEscapeError(abs, root)
let current = root
for (const segment of rel.split(sep)) {
if (segment === '' || segment === '.') continue
current = join(current, segment)
try {
const stats = await lstat(current)
if (stats.isSymbolicLink()) throw new PathEscapeError(abs, root)
} catch (err) {
if (err instanceof PathEscapeError) throw err
const code = (err as NodeJS.ErrnoException).code
// Missing/not-a-directory component: nothing below it can exist either.
if (code === 'ENOENT' || code === 'ENOTDIR') return
throw err
}
}
}
/** One filesystem entry as returned to the desktop. */
export interface FsEntry {
name: string
type: 'file' | 'dir'
size: number
mtimeMs: number
}
/** List the immediate children of a directory (async, non-blocking). */
export async function listDir(absPath: string): Promise<FsEntry[]> {
const entries = await readdir(absPath, { withFileTypes: true })
const result: FsEntry[] = []
for (const entry of entries) {
const st = await stat(join(absPath, entry.name))
result.push({
name: entry.name,
type: st.isDirectory() ? 'dir' : 'file',
size: st.isFile() ? st.size : 0,
mtimeMs: st.mtimeMs,
})
}
return result
}