(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:
Paul Fitzpatrick 2022-03-24 16:27:34 -04:00
parent de703343d0
commit 134ae99e9a
9 changed files with 482 additions and 41 deletions

View File

@ -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

View File

@ -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,
], ],

View File

@ -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) {

View File

@ -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;
} }
} }

View File

@ -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');
}

View 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
View 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!

View 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

View File

@ -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 ||