mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) add gvisor-based sandboxing to core
Summary: This adds support for gvisor sandboxing in core. When Grist is run outside of a container, regular gvisor can be used (if on linux), and will run in rootless mode. When Grist is run inside a container, docker's default policy is insufficient for running gvisor, so a fork of gvisor is used that has less defence-in-depth but can run without privileges. Sandboxing is automatically turned on in the Grist core container. It is not turned on automatically when built from source, since it is operating-system dependent. This diff may break a complex method of testing Grist with gvisor on macs that I may have been the only person using. If anyone complains I'll find time on a mac to fix it :) This diff includes a small "easter egg" to force document loads, primarily intended for developer use. Test Plan: existing tests pass; checked that core and saas docker builds function Reviewers: alexmojaki Reviewed By: alexmojaki Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3333
This commit is contained in:
parent
de703343d0
commit
134ae99e9a
45
Dockerfile
45
Dockerfile
@ -34,6 +34,15 @@ RUN \
|
|||||||
pip2 install -r requirements.txt && \
|
pip2 install -r requirements.txt && \
|
||||||
pip3 install -r requirements3.txt
|
pip3 install -r requirements3.txt
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
## Sandbox collection stage
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Fetch gvisor-based sandbox. Note, to enable it to run within default
|
||||||
|
# unprivileged docker, layers of protection that require privilege have
|
||||||
|
# been stripped away, see https://github.com/google/gvisor/issues/4371
|
||||||
|
FROM gristlabs/gvisor-unprivileged:buster as sandbox
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
## Run-time stage
|
## Run-time stage
|
||||||
################################################################################
|
################################################################################
|
||||||
@ -42,9 +51,10 @@ RUN \
|
|||||||
FROM node:14-buster-slim
|
FROM node:14-buster-slim
|
||||||
|
|
||||||
# Install libexpat1, libsqlite3-0 for python3 library binary dependencies.
|
# Install libexpat1, libsqlite3-0 for python3 library binary dependencies.
|
||||||
|
# Install pgrep for managing gvisor processes.
|
||||||
RUN \
|
RUN \
|
||||||
apt-get update && \
|
apt-get update && \
|
||||||
apt-get install -y --no-install-recommends libexpat1 libsqlite3-0 && \
|
apt-get install -y --no-install-recommends libexpat1 libsqlite3-0 procps && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Keep all storage user may want to persist in a distinct directory
|
# Keep all storage user may want to persist in a distinct directory
|
||||||
@ -63,7 +73,13 @@ COPY --from=collector /usr/local/bin/python3.9 /usr/bin/python3.9
|
|||||||
COPY --from=collector /usr/local/lib/python3.9 /usr/local/lib/python3.9
|
COPY --from=collector /usr/local/lib/python3.9 /usr/local/lib/python3.9
|
||||||
COPY --from=collector /usr/local/lib/libpython3.9.* /usr/local/lib/
|
COPY --from=collector /usr/local/lib/libpython3.9.* /usr/local/lib/
|
||||||
# Set default to python3
|
# Set default to python3
|
||||||
RUN ln -s /usr/bin/python3.9 /usr/bin/python && ldconfig
|
RUN \
|
||||||
|
ln -s /usr/bin/python3.9 /usr/bin/python && \
|
||||||
|
ln -s /usr/bin/python3.9 /usr/bin/python3 && \
|
||||||
|
ldconfig
|
||||||
|
|
||||||
|
# Copy runsc.
|
||||||
|
COPY --from=sandbox /runsc /usr/bin/runsc
|
||||||
|
|
||||||
# Add files needed for running server.
|
# Add files needed for running server.
|
||||||
ADD package.json package.json
|
ADD package.json package.json
|
||||||
@ -76,14 +92,19 @@ ADD plugins plugins
|
|||||||
# started as:
|
# started as:
|
||||||
# docker run -p 8484:8484 -it <image>
|
# docker run -p 8484:8484 -it <image>
|
||||||
# Variables will need to be overridden for other setups.
|
# Variables will need to be overridden for other setups.
|
||||||
ENV PYTHON_VERSION_ON_CREATION=3
|
ENV \
|
||||||
ENV GRIST_ORG_IN_PATH=true
|
PYTHON_VERSION_ON_CREATION=3 \
|
||||||
ENV GRIST_HOST=0.0.0.0
|
GRIST_ORG_IN_PATH=true \
|
||||||
ENV GRIST_SINGLE_PORT=true
|
GRIST_HOST=0.0.0.0 \
|
||||||
ENV GRIST_SERVE_SAME_ORIGIN=true
|
GRIST_SINGLE_PORT=true \
|
||||||
ENV GRIST_DATA_DIR=/persist/docs
|
GRIST_SERVE_SAME_ORIGIN=true \
|
||||||
ENV GRIST_INST_DIR=/persist
|
GRIST_DATA_DIR=/persist/docs \
|
||||||
ENV GRIST_SESSION_COOKIE=grist_core
|
GRIST_INST_DIR=/persist \
|
||||||
ENV TYPEORM_DATABASE=/persist/home.sqlite3
|
GRIST_SESSION_COOKIE=grist_core \
|
||||||
|
GVISOR_FLAGS="-unprivileged -ignore-cgroups" \
|
||||||
|
GRIST_SANDBOX_FLAVOR=gvisor \
|
||||||
|
TYPEORM_DATABASE=/persist/home.sqlite3
|
||||||
|
|
||||||
EXPOSE 8484
|
EXPOSE 8484
|
||||||
CMD yarn run start:prod
|
|
||||||
|
CMD ./sandbox/run.sh
|
||||||
|
@ -52,7 +52,17 @@ export async function showDocSettingsModal(docInfo: DocInfoRec, docPageModel: Do
|
|||||||
{defaultCurrencyLabel: `Local currency (${getCurrency(l)})`})
|
{defaultCurrencyLabel: `Local currency (${getCurrency(l)})`})
|
||||||
)),
|
)),
|
||||||
canChangeEngine ? [
|
canChangeEngine ? [
|
||||||
cssDataRow('Engine (experimental ☠ change at own risk):'),
|
// Small easter egg: you can click on the skull-and-crossbones to
|
||||||
|
// force a reload of the document.
|
||||||
|
cssDataRow('Engine (experimental ',
|
||||||
|
dom('span',
|
||||||
|
'☠',
|
||||||
|
dom.style('cursor', 'pointer'),
|
||||||
|
dom.on('click', async () => {
|
||||||
|
await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload();
|
||||||
|
document.location.reload();
|
||||||
|
})),
|
||||||
|
' change at own risk):'),
|
||||||
select(engineObs, getSupportedEngineChoices()),
|
select(engineObs, getSupportedEngineChoices()),
|
||||||
] : null,
|
] : null,
|
||||||
],
|
],
|
||||||
|
@ -1574,6 +1574,12 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
doc.id = docId || makeId();
|
doc.id = docId || makeId();
|
||||||
doc.checkProperties(props);
|
doc.checkProperties(props);
|
||||||
doc.updateFromProperties(props);
|
doc.updateFromProperties(props);
|
||||||
|
// For some reason, isPinned defaulting to null, not false,
|
||||||
|
// for some typeorm/postgres combination? That causes a
|
||||||
|
// constraint violation.
|
||||||
|
if (!doc.isPinned) {
|
||||||
|
doc.isPinned = false;
|
||||||
|
}
|
||||||
// By default, assign a urlId that is a prefix of the docId.
|
// By default, assign a urlId that is a prefix of the docId.
|
||||||
// The urlId should be unique across all existing documents.
|
// The urlId should be unique across all existing documents.
|
||||||
if (!doc.urlId) {
|
if (!doc.urlId) {
|
||||||
|
@ -153,7 +153,7 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
private _muted: boolean = false; // If set, changes to this document should not propagate
|
private _muted: boolean = false; // If set, changes to this document should not propagate
|
||||||
// to outside world
|
// to outside world
|
||||||
private _migrating: number = 0; // If positive, a migration is in progress
|
private _migrating: number = 0; // If positive, a migration is in progress
|
||||||
private _initializationPromise: Promise<boolean>|null = null;
|
private _initializationPromise: Promise<void>|null = null;
|
||||||
// If set, wait on this to be sure the ActiveDoc is fully
|
// If set, wait on this to be sure the ActiveDoc is fully
|
||||||
// initialized. True on success.
|
// initialized. True on success.
|
||||||
private _fullyLoaded: boolean = false; // Becomes true once all columns are loaded/computed.
|
private _fullyLoaded: boolean = false; // Becomes true once all columns are loaded/computed.
|
||||||
@ -668,12 +668,7 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
* Makes sure document is completely initialized. May throw if doc is broken.
|
* Makes sure document is completely initialized. May throw if doc is broken.
|
||||||
*/
|
*/
|
||||||
public async waitForInitialization() {
|
public async waitForInitialization() {
|
||||||
if (this._initializationPromise) {
|
await this._initializationPromise;
|
||||||
if (!await this._initializationPromise) {
|
|
||||||
throw new Error('ActiveDoc initialization failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has rights to download this doc.
|
// Check if user has rights to download this doc.
|
||||||
@ -1587,7 +1582,7 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
@ActiveDoc.keepDocOpen
|
@ActiveDoc.keepDocOpen
|
||||||
private async _finishInitialization(
|
private async _finishInitialization(
|
||||||
docSession: OptDocSession, pendingTableNames: string[], onDemandNames: string[], startTime: number
|
docSession: OptDocSession, pendingTableNames: string[], onDemandNames: string[], startTime: number
|
||||||
) {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this._tableMetadataLoader.wait();
|
await this._tableMetadataLoader.wait();
|
||||||
await this._tableMetadataLoader.clean();
|
await this._tableMetadataLoader.clean();
|
||||||
@ -1616,13 +1611,12 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
const closeTimeout = Math.max(loadMs, 1000) * Deps.ACTIVEDOC_TIMEOUT;
|
const closeTimeout = Math.max(loadMs, 1000) * Deps.ACTIVEDOC_TIMEOUT;
|
||||||
this._inactivityTimer.setDelay(closeTimeout);
|
this._inactivityTimer.setDelay(closeTimeout);
|
||||||
this._log.debug(docSession, `loaded in ${loadMs} ms, InactivityTimer set to ${closeTimeout} ms`);
|
this._log.debug(docSession, `loaded in ${loadMs} ms, InactivityTimer set to ${closeTimeout} ms`);
|
||||||
return true;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
this._fullyLoaded = true;
|
||||||
if (!this._shuttingDown) {
|
if (!this._shuttingDown) {
|
||||||
this._log.warn(docSession, "_finishInitialization stopped with %s", err);
|
this._log.warn(docSession, "_finishInitialization stopped with %s", err);
|
||||||
|
throw new Error('ActiveDoc initialization failed: ' + String(err));
|
||||||
}
|
}
|
||||||
this._fullyLoaded = true;
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,7 +174,7 @@ export class NSandbox implements ISandbox {
|
|||||||
await this._control.kill();
|
await this._control.kill();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
const result = await new Promise((resolve, reject) => {
|
const result = await new Promise<void>((resolve, reject) => {
|
||||||
if (this._isWriteClosed) { resolve(); }
|
if (this._isWriteClosed) { resolve(); }
|
||||||
this.childProc.on('error', reject);
|
this.childProc.on('error', reject);
|
||||||
this.childProc.on('close', resolve);
|
this.childProc.on('close', resolve);
|
||||||
@ -283,6 +283,7 @@ export class NSandbox implements ISandbox {
|
|||||||
this._isReadClosed = true;
|
this._isReadClosed = true;
|
||||||
// Clear out all reads pending on PipeFromSandbox, rejecting them with the given error.
|
// Clear out all reads pending on PipeFromSandbox, rejecting them with the given error.
|
||||||
const err = new sandboxUtil.SandboxError("PipeFromSandbox is closed");
|
const err = new sandboxUtil.SandboxError("PipeFromSandbox is closed");
|
||||||
|
|
||||||
this._pendingReads.forEach(resolvePair => resolvePair[1](err));
|
this._pendingReads.forEach(resolvePair => resolvePair[1](err));
|
||||||
this._pendingReads = [];
|
this._pendingReads = [];
|
||||||
}
|
}
|
||||||
@ -343,6 +344,10 @@ const spawners = {
|
|||||||
macSandboxExec, // Use "sandbox-exec" on Mac.
|
macSandboxExec, // Use "sandbox-exec" on Mac.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isFlavor(flavor: string): flavor is keyof typeof spawners {
|
||||||
|
return flavor in spawners;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A sandbox factory. This doesn't do very much beyond remembering a default
|
* A sandbox factory. This doesn't do very much beyond remembering a default
|
||||||
* flavor of sandbox (which at the time of writing differs between hosted grist and
|
* flavor of sandbox (which at the time of writing differs between hosted grist and
|
||||||
@ -369,20 +374,16 @@ export class NSandboxCreator implements ISandboxCreator {
|
|||||||
|
|
||||||
public constructor(options: {
|
public constructor(options: {
|
||||||
defaultFlavor: keyof typeof spawners,
|
defaultFlavor: keyof typeof spawners,
|
||||||
ignoreEnvironment?: boolean,
|
|
||||||
command?: string,
|
command?: string,
|
||||||
preferredPythonVersion?: string,
|
preferredPythonVersion?: string,
|
||||||
}) {
|
}) {
|
||||||
const flavor = (!options.ignoreEnvironment && process.env.GRIST_SANDBOX_FLAVOR) ||
|
const flavor = options.defaultFlavor;
|
||||||
options.defaultFlavor;
|
if (!isFlavor(flavor)) {
|
||||||
if (!Object.keys(spawners).includes(flavor)) {
|
|
||||||
throw new Error(`Unrecognized sandbox flavor: ${flavor}`);
|
throw new Error(`Unrecognized sandbox flavor: ${flavor}`);
|
||||||
}
|
}
|
||||||
this._flavor = flavor as keyof typeof spawners;
|
this._flavor = flavor;
|
||||||
this._command = (!options.ignoreEnvironment && process.env.GRIST_SANDBOX) ||
|
this._command = options.command;
|
||||||
options.command;
|
this._preferredPythonVersion = options.preferredPythonVersion;
|
||||||
this._preferredPythonVersion = (!options.ignoreEnvironment && process.env.PYTHON_VERSION) ||
|
|
||||||
options.preferredPythonVersion;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public create(options: ISandboxCreationOptions): ISandbox {
|
public create(options: ISandboxCreationOptions): ISandbox {
|
||||||
@ -506,8 +507,20 @@ function unsandboxed(options: ISandboxOptions): SandboxProcess {
|
|||||||
* Be sure to read setup instructions in that directory.
|
* Be sure to read setup instructions in that directory.
|
||||||
*/
|
*/
|
||||||
function gvisor(options: ISandboxOptions): SandboxProcess {
|
function gvisor(options: ISandboxOptions): SandboxProcess {
|
||||||
const {command, args: pythonArgs} = options;
|
const {args: pythonArgs} = options;
|
||||||
if (!command) { throw new Error("gvisor operation requires GRIST_SANDBOX"); }
|
let command = options.command;
|
||||||
|
if (!command) {
|
||||||
|
try {
|
||||||
|
// If runsc is available directly on the host, use the wrapper
|
||||||
|
// utility in sandbox/gvisor/run.py to run it.
|
||||||
|
which.sync('runsc');
|
||||||
|
command = 'sandbox/gvisor/run.py';
|
||||||
|
} catch(e) {
|
||||||
|
// Otherwise, don't try any heroics, user will need to
|
||||||
|
// explicitly set the command.
|
||||||
|
throw new Error('runsc not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!options.minimalPipeMode) {
|
if (!options.minimalPipeMode) {
|
||||||
throw new Error("gvisor only supports 3-pipe operation");
|
throw new Error("gvisor only supports 3-pipe operation");
|
||||||
}
|
}
|
||||||
@ -530,6 +543,22 @@ function gvisor(options: ISandboxOptions): SandboxProcess {
|
|||||||
if (pythonVersion !== '2' && pythonVersion !== '3') {
|
if (pythonVersion !== '2' && pythonVersion !== '3') {
|
||||||
throw new Error("PYTHON_VERSION must be set to 2 or 3");
|
throw new Error("PYTHON_VERSION must be set to 2 or 3");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for local virtual environments created with core's
|
||||||
|
// install:python2 or install:python3 targets. They'll need
|
||||||
|
// some extra sharing to make available in the sandbox.
|
||||||
|
// This appears to currently be incompatible with checkpoints?
|
||||||
|
// Shares and checkpoints interact delicately because the file
|
||||||
|
// handle layout/ordering needs to remain exactly the same.
|
||||||
|
// Fixable no doubt, but for now I just disable this convenience
|
||||||
|
// if checkpoints are in use.
|
||||||
|
const venv = path.join(process.cwd(),
|
||||||
|
pythonVersion === '2' ? 'venv' : 'sandbox_venv3');
|
||||||
|
if (fs.existsSync(venv) && !process.env.GRIST_CHECKPOINT) {
|
||||||
|
wrapperArgs.addMount(venv);
|
||||||
|
wrapperArgs.push('-s', path.join(venv, 'bin', 'python'));
|
||||||
|
}
|
||||||
|
|
||||||
// For a regular sandbox not being used for importing, if GRIST_CHECKPOINT is set
|
// For a regular sandbox not being used for importing, if GRIST_CHECKPOINT is set
|
||||||
// try to restore from it. If GRIST_CHECKPOINT_MAKE is set, try to recreate the
|
// try to restore from it. If GRIST_CHECKPOINT_MAKE is set, try to recreate the
|
||||||
// checkpoint (this is an awkward place to do it, but avoids mismatches
|
// checkpoint (this is an awkward place to do it, but avoids mismatches
|
||||||
@ -831,3 +860,41 @@ function findPython(command: string|undefined, preferredVersion?: string) {
|
|||||||
}
|
}
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a sandbox. The defaultFlavorSpec is a guide to which sandbox
|
||||||
|
* to create, based on the desired python version. Examples:
|
||||||
|
* unsandboxed # no sandboxing
|
||||||
|
* 2:pynbox,gvisor # run python2 in pynbox, anything else in gvisor
|
||||||
|
* 3:macSandboxExec,docker # run python3 with sandbox-exec, anything else in docker
|
||||||
|
* If no particular python version is desired, the first sandbox listed will be used.
|
||||||
|
* The defaultFlavorSpec can be overridden by GRIST_SANDBOX_FLAVOR.
|
||||||
|
* The commands run can be overridden by GRIST_SANDBOX2 (for python2), GRIST_SANDBOX3 (for python3),
|
||||||
|
* or GRIST_SANDBOX (for either, if more specific variable is not specified).
|
||||||
|
* For documents with no preferred python version specified,
|
||||||
|
* PYTHON_VERSION_ON_CREATION or PYTHON_VERSION is used.
|
||||||
|
*/
|
||||||
|
export function createSandbox(defaultFlavorSpec: string, options: ISandboxCreationOptions): ISandbox {
|
||||||
|
const flavors = (process.env.GRIST_SANDBOX_FLAVOR || defaultFlavorSpec).split(',');
|
||||||
|
const preferredPythonVersion = options.preferredPythonVersion ||
|
||||||
|
process.env.PYTHON_VERSION_ON_CREATION ||
|
||||||
|
process.env.PYTHON_VERSION;
|
||||||
|
for (const flavorAndVersion of flavors) {
|
||||||
|
const parts = flavorAndVersion.trim().split(':', 2);
|
||||||
|
const flavor = parts[parts.length - 1];
|
||||||
|
const version = parts.length === 2 ? parts[0] : '*';
|
||||||
|
if (preferredPythonVersion === version || version === '*' || !preferredPythonVersion) {
|
||||||
|
if (!isFlavor(flavor)) {
|
||||||
|
throw new Error(`Unrecognized sandbox flavor: ${flavor}`);
|
||||||
|
}
|
||||||
|
const creator = new NSandboxCreator({
|
||||||
|
defaultFlavor: flavor,
|
||||||
|
command: process.env['GRIST_SANDBOX' + (preferredPythonVersion||'')] ||
|
||||||
|
process.env['GRIST_SANDBOX'],
|
||||||
|
preferredPythonVersion,
|
||||||
|
});
|
||||||
|
return creator.create(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Failed to create a sandbox');
|
||||||
|
}
|
||||||
|
48
sandbox/gvisor/get_checkpoint_path.sh
Executable file
48
sandbox/gvisor/get_checkpoint_path.sh
Executable file
@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# This defines a GRIST_CHECKPOINT environment variable, where we will store
|
||||||
|
# a sandbox checkpoint. The path is in principle arbitrary. In practice,
|
||||||
|
# it is helpful if it lies outside of the Grist repo (to avoid permission
|
||||||
|
# problems with docker users), but is distinct for each possible location
|
||||||
|
# of the Grist repo (to avoid collisions in distinct Jenkins jobs).
|
||||||
|
#
|
||||||
|
# Checkpointing is currently not supported by gvisor when running in
|
||||||
|
# rootless mode. Rootless mode is nevertheless the best way to run
|
||||||
|
# "mainstream" unpatched gvisor for Grist. If gvisor is unpatched
|
||||||
|
# (does not have the --unprivileged flag from
|
||||||
|
# https://github.com/google/gvisor/issues/4371#issuecomment-700917549)
|
||||||
|
# then we do not define GRIST_CHECKPOINT and checkpoints will not be
|
||||||
|
# used. If the host is linux, performance seems just fine; in other
|
||||||
|
# configurations we've seen about a second delay in initial load of
|
||||||
|
# python due to a relatively sluggish file system.
|
||||||
|
#
|
||||||
|
# So as part of figuring whether to allow checkpoints, this script
|
||||||
|
# determines the best flags to call gvisor with. It tries:
|
||||||
|
# --unprivileged --ignore-cgroups : for a newer rebased fork of gvisor
|
||||||
|
# --unprivileged : for an older fork of gvisor
|
||||||
|
# --rootless : unforked gvisor
|
||||||
|
# It leaves the flags in a GVISOR_FLAGS environment variable. This
|
||||||
|
# variable is respected by the sandbox/gvisor/run.py wrapper for running
|
||||||
|
# python in gvisor.
|
||||||
|
|
||||||
|
function check_gvisor {
|
||||||
|
# If we already have working gvisor flags, return.
|
||||||
|
if [[ -n "$GVISOR_FLAGS" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
# Check if a trivial command works under gvisor with the proposed flags.
|
||||||
|
if runsc --network none "$@" do true 2> /dev/null; then
|
||||||
|
export GVISOR_FLAGS="$@"
|
||||||
|
export GVISOR_AVAILABLE=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_gvisor --unprivileged --ignore-cgroups
|
||||||
|
check_gvisor --unprivileged
|
||||||
|
|
||||||
|
# If we can't use --unprivileged, stick with --rootless and no checkpoint
|
||||||
|
if [[ -z "$GVISOR_FLAGS" ]]; then
|
||||||
|
check_gvisor --rootless
|
||||||
|
else
|
||||||
|
export GRIST_CHECKPOINT=/tmp/engine_$(echo $PWD | sed "s/[^a-zA-Z0-9]/_/g")
|
||||||
|
fi
|
264
sandbox/gvisor/run.py
Executable file
264
sandbox/gvisor/run.py
Executable file
@ -0,0 +1,264 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# Run a command under gvisor, setting environment variables and sharing certain
|
||||||
|
# directories in read only mode. Specialized for running python, and (for testing)
|
||||||
|
# bash. Does not change directory structure, for unprivileged operation.
|
||||||
|
|
||||||
|
# Contains plenty of hard-coded paths that assume we are running within
|
||||||
|
# a container.
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Separate arguments before and after a -- divider.
|
||||||
|
from itertools import groupby
|
||||||
|
all_args = sys.argv[1:]
|
||||||
|
all_args = [list(group) for k, group in groupby(all_args, lambda x: x == "--") if not k]
|
||||||
|
main_args = all_args[0] # args before the -- divider, for this script.
|
||||||
|
more_args = all_args[1] if len(all_args) > 1 else [] # args after the -- divider
|
||||||
|
# to pass on to python/bash.
|
||||||
|
|
||||||
|
# Set up options.
|
||||||
|
parser = argparse.ArgumentParser(description='Run something in gvisor (runsc).')
|
||||||
|
parser.add_argument('command', choices=['bash', 'python2', 'python3'])
|
||||||
|
parser.add_argument('--dry-run', '-d', action='store_true',
|
||||||
|
help="print config")
|
||||||
|
parser.add_argument('--env', '-E', action='append')
|
||||||
|
parser.add_argument('--mount', '-m', action='append')
|
||||||
|
parser.add_argument('--restore', '-r')
|
||||||
|
parser.add_argument('--checkpoint', '-c')
|
||||||
|
parser.add_argument('--start', '-s') # allow overridding the entrypoint
|
||||||
|
parser.add_argument('--faketime')
|
||||||
|
|
||||||
|
# If CHECK_FOR_TERMINAL is set, just determine whether we will be running bash, and
|
||||||
|
# exit with success if so. This is so if we are being wrapped in docker, it can be
|
||||||
|
# started in interactive mode.
|
||||||
|
if os.environ.get('CHECK_FOR_TERMINAL') == '1':
|
||||||
|
args = parser.parse_args(main_args)
|
||||||
|
exit(0 if args.command == 'bash' else -1)
|
||||||
|
|
||||||
|
args = parser.parse_args(main_args)
|
||||||
|
|
||||||
|
include_bash = args.command == 'bash'
|
||||||
|
include_python2 = args.command == 'python2'
|
||||||
|
include_python3 = args.command == 'python3'
|
||||||
|
|
||||||
|
# Basic settings for gvisor's runsc. This follows the standard OCI specification:
|
||||||
|
# https://github.com/opencontainers/runtime-spec/blob/master/config.md
|
||||||
|
cmd_args = []
|
||||||
|
mounts = [ # These will be filled in more fully programmatically below.
|
||||||
|
{
|
||||||
|
"destination": "/proc", # gvisor virtualizes /proc
|
||||||
|
"source": "/proc",
|
||||||
|
"type": "/procfs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"destination": "/sys", # gvisor virtualizes /sys
|
||||||
|
"source": "/sys",
|
||||||
|
"type": "/sysfs",
|
||||||
|
"options": [
|
||||||
|
"nosuid",
|
||||||
|
"noexec",
|
||||||
|
"nodev",
|
||||||
|
"ro"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
preserved = set()
|
||||||
|
env = [
|
||||||
|
"PATH=/usr/local/bin:/usr/bin:/bin",
|
||||||
|
"LD_LIBRARY_PATH=/usr/local/lib" # Assumes python version in /usr/local
|
||||||
|
] + (args.env or [])
|
||||||
|
settings = {
|
||||||
|
"ociVersion": "1.0.0",
|
||||||
|
"process": {
|
||||||
|
"terminal": include_bash,
|
||||||
|
"user": {
|
||||||
|
"uid": os.getuid(), # match current user id, for convenience with mounts
|
||||||
|
"gid": 0
|
||||||
|
},
|
||||||
|
"args": cmd_args,
|
||||||
|
"env": env,
|
||||||
|
"cwd": "/",
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"path": "/", # The fork of gvisor we use shares paths with host.
|
||||||
|
"readonly": True # Read-only access by default, and we will blank out most
|
||||||
|
# of the host with empty "tmpfs" mounts.
|
||||||
|
},
|
||||||
|
"hostname": "gristland",
|
||||||
|
"mounts": mounts,
|
||||||
|
"linux": {
|
||||||
|
"namespaces": [
|
||||||
|
{
|
||||||
|
"type": "pid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "network"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ipc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "uts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "mount"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper for preparing a mount.
|
||||||
|
def preserve(*locations, short_failure=False):
|
||||||
|
for location in locations:
|
||||||
|
# Check the requested directory is visible on the host, and that there hasn't been a
|
||||||
|
# muddle. For Grist, this could happen if a parent directory of a temporary import
|
||||||
|
# directory hasn't been made available to the container this code runs in, for example.
|
||||||
|
if not os.path.exists(location):
|
||||||
|
if short_failure:
|
||||||
|
raise Exception('cannot find: ' + location)
|
||||||
|
raise Exception('cannot find: ' + location + ' ' +
|
||||||
|
'(if tmp path, make sure TMPDIR when running grist and GRIST_TMP line up)')
|
||||||
|
mounts.append({
|
||||||
|
"destination": location,
|
||||||
|
"source": location,
|
||||||
|
"options": ["ro"],
|
||||||
|
"type": "bind"
|
||||||
|
})
|
||||||
|
preserved.add(location)
|
||||||
|
|
||||||
|
# Prepare the file system - blank out everything that need not be shared.
|
||||||
|
exceptions = ["lib", "lib64"] # to be shared (read-only)
|
||||||
|
exceptions += ["proc", "sys"] # already virtualized
|
||||||
|
|
||||||
|
# retain /bin and /usr/bin for utilities
|
||||||
|
start = args.start
|
||||||
|
if include_bash or start:
|
||||||
|
exceptions.append("bin")
|
||||||
|
preserve("/usr/bin")
|
||||||
|
|
||||||
|
preserve("/usr/local/lib")
|
||||||
|
if os.path.exists('/lib64'):
|
||||||
|
preserve("/lib64")
|
||||||
|
if os.path.exists('/usr/lib64'):
|
||||||
|
preserve("/usr/lib64")
|
||||||
|
preserve("/usr/lib")
|
||||||
|
|
||||||
|
# include python3 for bash and python3
|
||||||
|
best = None
|
||||||
|
if not include_python2:
|
||||||
|
# We expect python3 in /usr/bin or /usr/local/bin.
|
||||||
|
candidates = [
|
||||||
|
path
|
||||||
|
# Pick the most generic python if not matching python3.9.
|
||||||
|
# Sorry this is delicate because of restores, mounts, symlinks.
|
||||||
|
for pattern in ['python3.9', 'python3', 'python3*']
|
||||||
|
for root in ['/usr/local', '/usr']
|
||||||
|
for path in glob.glob(f'{root}/bin/{pattern}')
|
||||||
|
if os.path.exists(path)
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
raise Exception('could not find python3')
|
||||||
|
best = os.path.realpath(candidates[0])
|
||||||
|
preserve(best)
|
||||||
|
|
||||||
|
# include python2 for bash and python2
|
||||||
|
if not include_python3:
|
||||||
|
# Try to include python2 only if it is present or we were specifically asked for it.
|
||||||
|
# This is to facilitate testing on a python3-only container.
|
||||||
|
if os.path.exists("/usr/bin/python2.7") or include_python2:
|
||||||
|
preserve("/usr/bin/python2.7", short_failure=True)
|
||||||
|
best = "/usr/bin/python2.7"
|
||||||
|
preserve("/usr/lib")
|
||||||
|
|
||||||
|
# Set up any specific shares requested.
|
||||||
|
if args.mount:
|
||||||
|
preserve(*args.mount)
|
||||||
|
|
||||||
|
for directory in os.listdir('/'):
|
||||||
|
if directory not in exceptions and ("/" + directory) not in preserved:
|
||||||
|
mounts.insert(0, {
|
||||||
|
"destination": "/" + directory,
|
||||||
|
"type": "tmpfs" # This places an empty directory at this destination.
|
||||||
|
})
|
||||||
|
|
||||||
|
# Set up faketime inside the sandbox if requested. Can't be set up outside the sandbox,
|
||||||
|
# because gvisor is written in Go and doesn't use the standard library that faketime
|
||||||
|
# tweaks.
|
||||||
|
if args.faketime:
|
||||||
|
preserve('/usr/lib/x86_64-linux-gnu/faketime')
|
||||||
|
cmd_args.append('faketime')
|
||||||
|
cmd_args.append('-f')
|
||||||
|
cmd_args.append('2020-01-01 00:00:00' if args.faketime == 'default' else args.faketime)
|
||||||
|
preserve('/usr/bin/faketime')
|
||||||
|
preserve('/bin/date')
|
||||||
|
|
||||||
|
# Pick and set an initial entry point (bash or python).
|
||||||
|
if start:
|
||||||
|
cmd_args.append(start)
|
||||||
|
else:
|
||||||
|
cmd_args.append('bash' if include_bash else best)
|
||||||
|
|
||||||
|
# Add any requested arguments for the program that will be run.
|
||||||
|
cmd_args += more_args
|
||||||
|
|
||||||
|
# Helper for assembling a runsc command.
|
||||||
|
# Takes the directory to work in and a list of arguments to append.
|
||||||
|
def make_command(root_dir, action):
|
||||||
|
flag_string = os.environ.get('GVISOR_FLAGS') or '-rootless'
|
||||||
|
flags = flag_string.split(' ')
|
||||||
|
command = ["runsc",
|
||||||
|
"-root", "/tmp/runsc", # Place container information somewhere writable.
|
||||||
|
] + flags + [
|
||||||
|
"-network",
|
||||||
|
"none"] + action + [
|
||||||
|
root_dir.replace('/', '_')] # Derive an arbitrary container name.
|
||||||
|
return command
|
||||||
|
|
||||||
|
# Generate the OCI spec as config.json in a temporary directory, and either show
|
||||||
|
# it (if --dry-run) or pass it on to gvisor runsc.
|
||||||
|
with tempfile.TemporaryDirectory() as root: # pylint: disable=no-member
|
||||||
|
config_filename = os.path.join(root, 'config.json')
|
||||||
|
with open(config_filename, 'w') as fout:
|
||||||
|
json.dump(settings, fout, indent=2)
|
||||||
|
if args.dry_run:
|
||||||
|
with open(config_filename, 'r') as fin:
|
||||||
|
spec = json.load(fin)
|
||||||
|
print(json.dumps(spec, indent=2))
|
||||||
|
else:
|
||||||
|
if not args.checkpoint:
|
||||||
|
if args.restore:
|
||||||
|
command = make_command(root, ["restore", "--image-path=" + args.restore])
|
||||||
|
else:
|
||||||
|
command = make_command(root, ["run"])
|
||||||
|
result = subprocess.run(command, cwd=root) # pylint: disable=no-member
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise Exception('gvisor runsc problem: ' + json.dumps(command))
|
||||||
|
else:
|
||||||
|
# We've been asked to make a checkpoint.
|
||||||
|
# Start up the sandbox, and wait for it to emit a message on stderr ('Ready').
|
||||||
|
command = make_command(root, ["run"])
|
||||||
|
process = subprocess.Popen(command, cwd=root, stderr=subprocess.PIPE)
|
||||||
|
ready_line = process.stderr.readline() # wait for ready
|
||||||
|
sys.stderr.write('Ready message: ' + ready_line.decode('utf-8'))
|
||||||
|
sys.stderr.flush()
|
||||||
|
# Remove existing checkpoint if present.
|
||||||
|
if os.path.exists(os.path.join(args.checkpoint, 'checkpoint.img')):
|
||||||
|
os.remove(os.path.join(args.checkpoint, 'checkpoint.img'))
|
||||||
|
# Make the directory, so we will later have the right to delete the checkpoint if
|
||||||
|
# we wish to replace it. Otherwise there is a muddle around permissions.
|
||||||
|
if not os.path.exists(args.checkpoint):
|
||||||
|
os.makedirs(args.checkpoint)
|
||||||
|
# Go ahead and run the runsc checkpoint command.
|
||||||
|
# This is destructive, it will kill the sandbox we are checkpointing.
|
||||||
|
command = make_command(root, ["checkpoint", "--image-path=" + args.checkpoint])
|
||||||
|
result = subprocess.run(command, cwd=root) # pylint: disable=no-member
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise Exception('gvisor runsc checkpointing problem: ' + json.dumps(command))
|
||||||
|
# We are done!
|
34
sandbox/gvisor/update_engine_checkpoint.sh
Executable file
34
sandbox/gvisor/update_engine_checkpoint.sh
Executable file
@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Create a checkpoint of a gvisor sandbox. It is best to make the
|
||||||
|
# checkpoint in as close to the same circumstances as it will be used,
|
||||||
|
# because of some nuances around file descriptor ordering and
|
||||||
|
# mapping. So we create the checkpoint in a roundabout way, by opening
|
||||||
|
# node and creating an NSandbox, with appropriate flags set.
|
||||||
|
#
|
||||||
|
# Watch out if you feel tempted to simplify this, I initially had a
|
||||||
|
# much simpler solution that worked fine in docker, but on aws
|
||||||
|
# would result in a runsc panic related to file descriptor
|
||||||
|
# ordering/mapping.
|
||||||
|
#
|
||||||
|
# Note for mac users: the checkpoint will be made in the docker
|
||||||
|
# container running runsc.
|
||||||
|
|
||||||
|
set -ex
|
||||||
|
|
||||||
|
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||||
|
|
||||||
|
export NODE_PATH=_build:_build/core:_build/stubs
|
||||||
|
source $SCRIPT_DIR/get_checkpoint_path.sh
|
||||||
|
|
||||||
|
if [[ -z "GRIST_CHECKPOINT" ]]; then
|
||||||
|
echo "Skipping checkpoint generation"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
export GRIST_CHECKPOINT_MAKE=1
|
||||||
|
export GRIST_SANDBOX_FLAVOR=gvisor
|
||||||
|
export PYTHON_VERSION=3
|
||||||
|
|
||||||
|
BUILD=$(test -e _build/core && echo "_build/core" || echo "_build")
|
||||||
|
node $BUILD/app/server/generateCheckpoint.js
|
@ -1,9 +1,6 @@
|
|||||||
import {ActiveDoc} from 'app/server/lib/ActiveDoc';
|
import {ActiveDoc} from 'app/server/lib/ActiveDoc';
|
||||||
import {ICreate} from 'app/server/lib/ICreate';
|
import {ICreate} from 'app/server/lib/ICreate';
|
||||||
import {NSandboxCreator} from 'app/server/lib/NSandbox';
|
import {createSandbox} from 'app/server/lib/NSandbox';
|
||||||
|
|
||||||
// Use raw python - update when pynbox or other solution is set up for core.
|
|
||||||
const sandboxCreator = new NSandboxCreator({defaultFlavor: 'unsandboxed'});
|
|
||||||
|
|
||||||
export const create: ICreate = {
|
export const create: ICreate = {
|
||||||
Billing() {
|
Billing() {
|
||||||
@ -28,7 +25,7 @@ export const create: ICreate = {
|
|||||||
ExternalStorage() { return undefined; },
|
ExternalStorage() { return undefined; },
|
||||||
ActiveDoc(docManager, docName, options) { return new ActiveDoc(docManager, docName, options); },
|
ActiveDoc(docManager, docName, options) { return new ActiveDoc(docManager, docName, options); },
|
||||||
NSandbox(options) {
|
NSandbox(options) {
|
||||||
return sandboxCreator.create(options);
|
return createSandbox('unsandboxed', options);
|
||||||
},
|
},
|
||||||
sessionSecret() {
|
sessionSecret() {
|
||||||
return process.env.GRIST_SESSION_SECRET ||
|
return process.env.GRIST_SESSION_SECRET ||
|
||||||
|
Loading…
Reference in New Issue
Block a user