From 134ae99e9ab58dabc63a7f5107b3d67522fd023d Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Thu, 24 Mar 2022 16:27:34 -0400 Subject: [PATCH] (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 --- Dockerfile | 45 +++- app/client/ui/DocumentSettings.ts | 12 +- app/gen-server/lib/HomeDBManager.ts | 6 + app/server/lib/ActiveDoc.ts | 16 +- app/server/lib/NSandbox.ts | 91 ++++++- sandbox/gvisor/get_checkpoint_path.sh | 48 ++++ sandbox/gvisor/run.py | 264 +++++++++++++++++++++ sandbox/gvisor/update_engine_checkpoint.sh | 34 +++ stubs/app/server/lib/create.ts | 7 +- 9 files changed, 482 insertions(+), 41 deletions(-) create mode 100755 sandbox/gvisor/get_checkpoint_path.sh create mode 100755 sandbox/gvisor/run.py create mode 100755 sandbox/gvisor/update_engine_checkpoint.sh diff --git a/Dockerfile b/Dockerfile index 43605da6..16f2d25b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,15 @@ RUN \ pip2 install -r requirements.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 ################################################################################ @@ -42,9 +51,10 @@ RUN \ FROM node:14-buster-slim # Install libexpat1, libsqlite3-0 for python3 library binary dependencies. +# Install pgrep for managing gvisor processes. RUN \ 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/* # 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/libpython3.9.* /usr/local/lib/ # 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 package.json package.json @@ -76,14 +92,19 @@ ADD plugins plugins # started as: # docker run -p 8484:8484 -it # Variables will need to be overridden for other setups. -ENV PYTHON_VERSION_ON_CREATION=3 -ENV GRIST_ORG_IN_PATH=true -ENV GRIST_HOST=0.0.0.0 -ENV GRIST_SINGLE_PORT=true -ENV GRIST_SERVE_SAME_ORIGIN=true -ENV GRIST_DATA_DIR=/persist/docs -ENV GRIST_INST_DIR=/persist -ENV GRIST_SESSION_COOKIE=grist_core -ENV TYPEORM_DATABASE=/persist/home.sqlite3 +ENV \ + PYTHON_VERSION_ON_CREATION=3 \ + GRIST_ORG_IN_PATH=true \ + GRIST_HOST=0.0.0.0 \ + GRIST_SINGLE_PORT=true \ + GRIST_SERVE_SAME_ORIGIN=true \ + GRIST_DATA_DIR=/persist/docs \ + GRIST_INST_DIR=/persist \ + GRIST_SESSION_COOKIE=grist_core \ + GVISOR_FLAGS="-unprivileged -ignore-cgroups" \ + GRIST_SANDBOX_FLAVOR=gvisor \ + TYPEORM_DATABASE=/persist/home.sqlite3 + EXPOSE 8484 -CMD yarn run start:prod + +CMD ./sandbox/run.sh diff --git a/app/client/ui/DocumentSettings.ts b/app/client/ui/DocumentSettings.ts index 1075b69a..8a2ebbce 100644 --- a/app/client/ui/DocumentSettings.ts +++ b/app/client/ui/DocumentSettings.ts @@ -52,7 +52,17 @@ export async function showDocSettingsModal(docInfo: DocInfoRec, docPageModel: Do {defaultCurrencyLabel: `Local currency (${getCurrency(l)})`}) )), 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()), ] : null, ], diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index a690b06a..2cf13325 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -1574,6 +1574,12 @@ export class HomeDBManager extends EventEmitter { doc.id = docId || makeId(); doc.checkProperties(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. // The urlId should be unique across all existing documents. if (!doc.urlId) { diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index dd319edd..ca5bbae4 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -153,7 +153,7 @@ export class ActiveDoc extends EventEmitter { private _muted: boolean = false; // If set, changes to this document should not propagate // to outside world private _migrating: number = 0; // If positive, a migration is in progress - private _initializationPromise: Promise|null = null; + private _initializationPromise: Promise|null = null; // If set, wait on this to be sure the ActiveDoc is fully // initialized. True on success. 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. */ public async waitForInitialization() { - if (this._initializationPromise) { - if (!await this._initializationPromise) { - throw new Error('ActiveDoc initialization failed'); - } - } - return true; + await this._initializationPromise; } // Check if user has rights to download this doc. @@ -1587,7 +1582,7 @@ export class ActiveDoc extends EventEmitter { @ActiveDoc.keepDocOpen private async _finishInitialization( docSession: OptDocSession, pendingTableNames: string[], onDemandNames: string[], startTime: number - ) { + ): Promise { try { await this._tableMetadataLoader.wait(); await this._tableMetadataLoader.clean(); @@ -1616,13 +1611,12 @@ export class ActiveDoc extends EventEmitter { const closeTimeout = Math.max(loadMs, 1000) * Deps.ACTIVEDOC_TIMEOUT; this._inactivityTimer.setDelay(closeTimeout); this._log.debug(docSession, `loaded in ${loadMs} ms, InactivityTimer set to ${closeTimeout} ms`); - return true; } catch (err) { + this._fullyLoaded = true; if (!this._shuttingDown) { this._log.warn(docSession, "_finishInitialization stopped with %s", err); + throw new Error('ActiveDoc initialization failed: ' + String(err)); } - this._fullyLoaded = true; - return false; } } diff --git a/app/server/lib/NSandbox.ts b/app/server/lib/NSandbox.ts index 79af6376..e6112089 100644 --- a/app/server/lib/NSandbox.ts +++ b/app/server/lib/NSandbox.ts @@ -174,7 +174,7 @@ export class NSandbox implements ISandbox { await this._control.kill(); }, 1000); - const result = await new Promise((resolve, reject) => { + const result = await new Promise((resolve, reject) => { if (this._isWriteClosed) { resolve(); } this.childProc.on('error', reject); this.childProc.on('close', resolve); @@ -283,6 +283,7 @@ export class NSandbox implements ISandbox { this._isReadClosed = true; // Clear out all reads pending on PipeFromSandbox, rejecting them with the given error. const err = new sandboxUtil.SandboxError("PipeFromSandbox is closed"); + this._pendingReads.forEach(resolvePair => resolvePair[1](err)); this._pendingReads = []; } @@ -343,6 +344,10 @@ const spawners = { 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 * 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: { defaultFlavor: keyof typeof spawners, - ignoreEnvironment?: boolean, command?: string, preferredPythonVersion?: string, }) { - const flavor = (!options.ignoreEnvironment && process.env.GRIST_SANDBOX_FLAVOR) || - options.defaultFlavor; - if (!Object.keys(spawners).includes(flavor)) { + const flavor = options.defaultFlavor; + if (!isFlavor(flavor)) { throw new Error(`Unrecognized sandbox flavor: ${flavor}`); } - this._flavor = flavor as keyof typeof spawners; - this._command = (!options.ignoreEnvironment && process.env.GRIST_SANDBOX) || - options.command; - this._preferredPythonVersion = (!options.ignoreEnvironment && process.env.PYTHON_VERSION) || - options.preferredPythonVersion; + this._flavor = flavor; + this._command = options.command; + this._preferredPythonVersion = options.preferredPythonVersion; } public create(options: ISandboxCreationOptions): ISandbox { @@ -506,8 +507,20 @@ function unsandboxed(options: ISandboxOptions): SandboxProcess { * Be sure to read setup instructions in that directory. */ function gvisor(options: ISandboxOptions): SandboxProcess { - const {command, args: pythonArgs} = options; - if (!command) { throw new Error("gvisor operation requires GRIST_SANDBOX"); } + const {args: pythonArgs} = options; + 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) { throw new Error("gvisor only supports 3-pipe operation"); } @@ -530,6 +543,22 @@ function gvisor(options: ISandboxOptions): SandboxProcess { if (pythonVersion !== '2' && pythonVersion !== '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 // 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 @@ -831,3 +860,41 @@ function findPython(command: string|undefined, preferredVersion?: string) { } 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'); +} diff --git a/sandbox/gvisor/get_checkpoint_path.sh b/sandbox/gvisor/get_checkpoint_path.sh new file mode 100755 index 00000000..37b824c9 --- /dev/null +++ b/sandbox/gvisor/get_checkpoint_path.sh @@ -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 diff --git a/sandbox/gvisor/run.py b/sandbox/gvisor/run.py new file mode 100755 index 00000000..cd41111a --- /dev/null +++ b/sandbox/gvisor/run.py @@ -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! diff --git a/sandbox/gvisor/update_engine_checkpoint.sh b/sandbox/gvisor/update_engine_checkpoint.sh new file mode 100755 index 00000000..4f35def5 --- /dev/null +++ b/sandbox/gvisor/update_engine_checkpoint.sh @@ -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 diff --git a/stubs/app/server/lib/create.ts b/stubs/app/server/lib/create.ts index fbe67f53..62b22d39 100644 --- a/stubs/app/server/lib/create.ts +++ b/stubs/app/server/lib/create.ts @@ -1,9 +1,6 @@ import {ActiveDoc} from 'app/server/lib/ActiveDoc'; import {ICreate} from 'app/server/lib/ICreate'; -import {NSandboxCreator} 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'}); +import {createSandbox} from 'app/server/lib/NSandbox'; export const create: ICreate = { Billing() { @@ -28,7 +25,7 @@ export const create: ICreate = { ExternalStorage() { return undefined; }, ActiveDoc(docManager, docName, options) { return new ActiveDoc(docManager, docName, options); }, NSandbox(options) { - return sandboxCreator.create(options); + return createSandbox('unsandboxed', options); }, sessionSecret() { return process.env.GRIST_SESSION_SECRET ||