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 && \
|
||||
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 <image>
|
||||
# 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
|
||||
|
@ -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,
|
||||
],
|
||||
|
@ -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) {
|
||||
|
@ -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<boolean>|null = null;
|
||||
private _initializationPromise: Promise<void>|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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<void>((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');
|
||||
}
|
||||
|
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 {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 ||
|
||||
|
Loading…
Reference in New Issue
Block a user