213 lines
9.0 KiB
TypeScript
213 lines
9.0 KiB
TypeScript
import express, { Router } from 'express';
|
|
import { canManageSpace } from './visibility.js';
|
|
import { viewerOf } from './space-viewer.js';
|
|
import { logger } from '../logger.js';
|
|
import {
|
|
resolvePythonPackagesConfig,
|
|
parseAndValidateSpec,
|
|
assertNotShadowing,
|
|
installSpacePackages,
|
|
spaceKeyFor,
|
|
pipPreflight,
|
|
type ParsedSpec,
|
|
} from '../engine/python-packages.js';
|
|
import { isBwrapAvailable } from '../engine/tools/sandbox.js';
|
|
import type { SpaceApiDeps } from './space-api.js';
|
|
|
|
/**
|
|
* Per-space Python package management (admin form entry point).
|
|
*
|
|
* GET /:id/python-packages — viewer: list installed + feature state
|
|
* POST /:id/python-packages — canManageSpace: add a wheel + rebuild overlay
|
|
* DELETE /:id/python-packages/:name — canManageSpace: remove + rebuild overlay
|
|
*
|
|
* The persisted desired-state (a JSON array on spaces.python_packages) is the
|
|
* source of truth; each mutation rebuilds the whole content-addressed overlay
|
|
* and atomically repoints `current`. Installs run out-of-band in a separate,
|
|
* network-enabled bwrap with no workspace/secret access (see python-packages.ts).
|
|
*/
|
|
|
|
interface PersistedPkg {
|
|
name: string; // PEP 503 normalized
|
|
spec: string; // canonical pip argv, e.g. "requests==2.32.3"
|
|
addedAt: string;
|
|
}
|
|
interface PersistedState {
|
|
packages: PersistedPkg[];
|
|
lockHash?: string;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
function readState(json: string | null): PersistedState {
|
|
if (!json) return { packages: [] };
|
|
try {
|
|
const v = JSON.parse(json);
|
|
if (v && Array.isArray(v.packages)) return v as PersistedState;
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return { packages: [] };
|
|
}
|
|
|
|
// Per-space in-process mutex so two concurrent installs never race the overlay
|
|
// or the read-modify-write of the desired-state JSON.
|
|
const spaceLocks = new Map<string, Promise<unknown>>();
|
|
function withSpaceLock<T>(spaceId: string, fn: () => Promise<T>): Promise<T> {
|
|
const prev = spaceLocks.get(spaceId) ?? Promise.resolve();
|
|
const next = prev.catch(() => {}).then(fn);
|
|
spaceLocks.set(spaceId, next.catch(() => {}));
|
|
return next;
|
|
}
|
|
|
|
export function registerSpacePythonPackagesRoutes(router: Router, deps: SpaceApiDeps): void {
|
|
const { repo } = deps;
|
|
const jsonParser = express.json({ limit: '256kb' });
|
|
|
|
function cfg() {
|
|
return resolvePythonPackagesConfig(deps.getPythonPackagesConfig?.());
|
|
}
|
|
|
|
// Installs REQUIRE the isolated bwrap sandbox (fail-closed). Report pip + bwrap
|
|
// readiness so the UI can explain why the form is unavailable.
|
|
async function computePreflight(enabled: boolean): Promise<{ ok: boolean; reason?: string }> {
|
|
if (!enabled) return { ok: false, reason: 'feature disabled' };
|
|
if (!(await isBwrapAvailable())) {
|
|
return { ok: false, reason: 'The bwrap sandbox is unavailable on this server, so packages cannot be installed safely.' };
|
|
}
|
|
return pipPreflight();
|
|
}
|
|
|
|
// GET — viewer may read the list + feature state.
|
|
router.get('/:id/python-packages', async (req, res) => {
|
|
const viewer = viewerOf(req, deps.authActive);
|
|
const space = await repo.getSpace(req.params.id, { viewer });
|
|
if (!space) return res.status(404).json({ error: 'not found' });
|
|
const c = cfg();
|
|
const state = readState(repo.getSpacePythonPackages(space.id));
|
|
const preflight = await computePreflight(c.enabled);
|
|
// NOTE: indexUrl is deliberately NOT returned. A private index can embed
|
|
// credentials (https://user:token@host/simple); this endpoint is readable by
|
|
// any space viewer, so leaking it would expose the token to non-managers.
|
|
res.json({
|
|
enabled: c.enabled,
|
|
maxPackagesPerSpace: c.maxPackagesPerSpace,
|
|
preflight,
|
|
packages: state.packages,
|
|
});
|
|
});
|
|
|
|
// POST { spec } — canManageSpace: add one package and rebuild the overlay.
|
|
router.post('/:id/python-packages', jsonParser, async (req, res) => {
|
|
const viewer = viewerOf(req, deps.authActive);
|
|
const space = await repo.getSpace(req.params.id, { viewer });
|
|
if (!space) return res.status(404).json({ error: 'not found' });
|
|
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
|
|
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
|
|
return res.status(403).json({ error: 'forbidden' });
|
|
}
|
|
const c = cfg();
|
|
if (!c.enabled) return res.status(400).json({ error: 'Python package management is disabled (config: python_packages.enabled)' });
|
|
|
|
let parsed: ParsedSpec;
|
|
try {
|
|
parsed = parseAndValidateSpec(String(req.body?.spec ?? ''));
|
|
assertNotShadowing(parsed.normalizedName);
|
|
} catch (e) {
|
|
return res.status(400).json({ error: (e as Error).message });
|
|
}
|
|
|
|
const pre = await computePreflight(true);
|
|
if (!pre.ok) return res.status(503).json({ error: pre.reason });
|
|
|
|
try {
|
|
const result = await withSpaceLock(space.id, async () => {
|
|
const state = readState(repo.getSpacePythonPackages(space.id));
|
|
if (state.packages.some((p) => p.name === parsed.normalizedName)) {
|
|
return { conflict: true as const };
|
|
}
|
|
if (state.packages.length + 1 > c.maxPackagesPerSpace) {
|
|
return { tooMany: true as const };
|
|
}
|
|
// Rebuild the FULL set (existing + new) into a fresh overlay.
|
|
const specs: ParsedSpec[] = [
|
|
...state.packages.map((p) => parseAndValidateSpec(p.spec)),
|
|
parsed,
|
|
];
|
|
const install = await installSpacePackages({
|
|
pkgRoot: c.dir,
|
|
spaceKey: spaceKeyFor(space.id, space.ownerId),
|
|
specs,
|
|
cfg: c,
|
|
bwrapAvailable: await isBwrapAvailable(),
|
|
});
|
|
if (!install.ok) return { installError: install.error ?? 'install failed' };
|
|
const next: PersistedState = {
|
|
packages: [
|
|
...state.packages,
|
|
{ name: parsed.normalizedName, spec: parsed.spec, addedAt: new Date().toISOString() },
|
|
],
|
|
lockHash: install.lockHash,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
repo.setSpacePythonPackages(space.id, JSON.stringify(next));
|
|
return { ok: true as const, state: next };
|
|
});
|
|
|
|
if ('conflict' in result) return res.status(409).json({ error: `already installed: ${parsed.normalizedName}` });
|
|
if ('tooMany' in result) return res.status(400).json({ error: `too many packages (max ${c.maxPackagesPerSpace})` });
|
|
if ('installError' in result) return res.status(422).json({ error: result.installError });
|
|
logger.info(`[space-api] python-package added space=${space.id} pkg=${parsed.spec}`);
|
|
res.json({ packages: result.state.packages, lockHash: result.state.lockHash });
|
|
} catch (err) {
|
|
logger.error(`[space-api] python-package POST failed space=${space.id} err=${(err as Error).message}`);
|
|
res.status(500).json({ error: 'internal error' });
|
|
}
|
|
});
|
|
|
|
// DELETE /:name — canManageSpace: remove one package and rebuild the overlay.
|
|
router.delete('/:id/python-packages/:name', async (req, res) => {
|
|
const viewer = viewerOf(req, deps.authActive);
|
|
const space = await repo.getSpace(req.params.id, { viewer });
|
|
if (!space) return res.status(404).json({ error: 'not found' });
|
|
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
|
|
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
|
|
return res.status(403).json({ error: 'forbidden' });
|
|
}
|
|
const c = cfg();
|
|
if (!c.enabled) return res.status(400).json({ error: 'Python package management is disabled (config: python_packages.enabled)' });
|
|
const name = String(req.params.name || '').toLowerCase();
|
|
|
|
try {
|
|
const result = await withSpaceLock(space.id, async () => {
|
|
const state = readState(repo.getSpacePythonPackages(space.id));
|
|
const remaining = state.packages.filter((p) => p.name !== name);
|
|
if (remaining.length === state.packages.length) return { notFound: true as const };
|
|
const specs: ParsedSpec[] = remaining.map((p) => parseAndValidateSpec(p.spec));
|
|
const install = await installSpacePackages({
|
|
pkgRoot: c.dir,
|
|
spaceKey: spaceKeyFor(space.id, space.ownerId),
|
|
specs,
|
|
cfg: c,
|
|
bwrapAvailable: await isBwrapAvailable(),
|
|
});
|
|
if (!install.ok) return { installError: install.error ?? 'rebuild failed' };
|
|
const next: PersistedState = {
|
|
packages: remaining,
|
|
lockHash: install.lockHash,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
repo.setSpacePythonPackages(space.id, JSON.stringify(next));
|
|
return { ok: true as const, state: next };
|
|
});
|
|
|
|
if ('notFound' in result) return res.status(404).json({ error: `not installed: ${name}` });
|
|
if ('installError' in result) return res.status(422).json({ error: result.installError });
|
|
logger.info(`[space-api] python-package removed space=${space.id} pkg=${name}`);
|
|
res.json({ packages: result.state.packages, lockHash: result.state.lockHash });
|
|
} catch (err) {
|
|
logger.error(`[space-api] python-package DELETE failed space=${space.id} err=${(err as Error).message}`);
|
|
res.status(500).json({ error: 'internal error' });
|
|
}
|
|
});
|
|
}
|