diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f69cd00 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + test: + name: Julia ${{ matrix.version }} - ${{ matrix.build_spec }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + version: + - '1.6' + os: + - ubuntu-latest + arch: + - x64 + env: + JULIA_DEBUG: SandboxJulia + steps: + - uses: actions/checkout@v2 + - uses: julia-actions/setup-julia@v1 + with: + version: ${{ matrix.version }} + arch: ${{ matrix.arch }} + - uses: actions/cache@v1 + env: + cache-name: cache-artifacts + with: + path: ~/.julia/artifacts + key: ${{ runner.os }}-test-${{ env.cache-name }}-${{ hashFiles('**/Project.toml') }} + restore-keys: | + ${{ runner.os }}-test-${{ env.cache-name }}- + ${{ runner.os }}-test- + ${{ runner.os }}- + - uses: julia-actions/julia-buildpkg@latest + - run: | + git config --global user.name Tester + git config --global user.email te@st.er + - uses: julia-actions/julia-runtest@latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba39cc5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +Manifest.toml diff --git a/Artifacts.toml b/Artifacts.toml new file mode 100644 index 0000000..aa1a80e --- /dev/null +++ b/Artifacts.toml @@ -0,0 +1,15 @@ +[alpine] +git-tree-sha1 = "562768a40e93d27b79fbedf9cfa7883409d494ea" +lazy = true + + [[alpine.download]] + sha256 = "5a588162779446d8e5235bf6fc97588d1e197f44cf64e3a1f88ae828270456a7" + url = "https://github.com/alpinelinux/docker-alpine/raw/2f3c3015951938c521e752899a92ecd618e0c05b/x86_64/alpine-minirootfs-3.12.4-x86_64.tar.gz" + +[debian] +git-tree-sha1 = "629416ae3d28494fea097fedc57d0fdd748731e3" +lazy = true + + [[debian.download]] + sha256 = "3c3c78a1b15490bfdc29cfd10a72f5f16195ccf64ffc4cdfb445cad387ea5b50" + url = "https://github.com/JuliaCI/PkgEval.jl/releases/download/v0.1/debian-buster-20210420.tar.xz" diff --git a/Project.toml b/Project.toml new file mode 100644 index 0000000..d9da484 --- /dev/null +++ b/Project.toml @@ -0,0 +1,13 @@ +name = "SandboxJulia" +uuid = "00b7dd00-052f-4550-80aa-b019f336a5ea" +version = "0.1.0" + +[deps] +LazyArtifacts = "4af54fe1-eca0-43a8-85a7-787d91b784e3" +Sandbox = "9307e30f-c43e-9ca7-d17c-c2dc59df670d" + +[extras] +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[targets] +test = ["Test"] diff --git a/src/SandboxJulia.jl b/src/SandboxJulia.jl new file mode 100644 index 0000000..b43b697 --- /dev/null +++ b/src/SandboxJulia.jl @@ -0,0 +1,180 @@ +module SandboxJulia + +using LazyArtifacts, Sandbox +export run_sandboxed_julia + +isdebug(group) = Base.CoreLogging.current_logger_for_env(Base.CoreLogging.Debug, group, SandboxJulia) !== nothing + +lazy_artifact(x) = @artifact_str(x) + +const rootfs_lock = ReentrantLock() +const rootfs_cache = Dict() +function prepare_rootfs(distro="debian"; uid=1000, user="sandboxjulia", gid=1000, group="sandboxjulia", home="/home/$user") + lock(rootfs_lock) do + get!(rootfs_cache, (distro, uid, user, gid, group, home)) do + base = lazy_artifact(distro) + + # a bare rootfs isn't usable out-of-the-box + derived = mktempdir() + cp(base, derived; force=true) + + # add a user and group + chmod(joinpath(derived, "etc/passwd"), 0o644) + open(joinpath(derived, "etc/passwd"), "a") do io + println(io, "$user:x:$uid:$gid::$home:/bin/bash") + end + chmod(joinpath(derived, "etc/group"), 0o644) + open(joinpath(derived, "etc/group"), "a") do io + println(io, "$group:x:$gid:") + end + chmod(joinpath(derived, "etc/shadow"), 0o640) + open(joinpath(derived, "etc/shadow"), "a") do io + println(io, "$user:*:::::::") + end + + # replace resolv.conf + rm(joinpath(derived, "etc/resolv.conf"); force=true) + write(joinpath(derived, "etc/resolv.conf"), read("/etc/resolv.conf")) + + return (path=derived, uid, user, gid, group, home) + end + end +end + +""" + run_sandboxed_julia(install::String, args=``; env=Dict(), mounts=Dict(), + wait=true, stdin=stdin, stdout=stdout, stderr=stderr, + install_dir="/opt/julia", kwargs...) + +Run Julia inside of a sandbox, passing the given arguments `args` to it. The argument `wait` +determines if the process will be waited on. Streams can be connected using the `stdin`, +`stdout` and `sterr` arguments. Returns a `Process` object. + +Further customization is possible using the `env` arg, to set environment variables, and the +`mounts` argument to mount additional directories. With `install_dir`, the directory where +Julia is installed can be chosen. +""" +function run_sandboxed_julia(args=``; wait=true, + registries_dir=joinpath(first(DEPOT_PATH), "registries"), + storage_dir=mktempdir(), + install::String=dirname(dirname(Sys.which("julia"))), + mounts::Dict{String,String}=Dict{String,String}(), + privileged=false, + kwargs...) + config, cmd = runner_sandboxed_julia(install, args; storage_dir, registries_dir, kwargs...) + + # XXX: even when preferred_executor() returns UnprivilegedUserNamespacesExecutor, + # sometimes a stray sudo happens at run time? no idea how. + exe_typ = privileged ? PrivilegedUserNamespacesExecutor : UnprivilegedUserNamespacesExecutor + exe = exe_typ() + proc = Base.run(exe, config, cmd; wait) + + # TODO: introduce a --stats flag that has the sandbox trace and report on CPU, network, ... usage + + if wait + cleanup(exe) + else + @async begin + try + Base.wait(proc) + cleanup(exe) + catch err + @error "Unexpected error while cleaning up process" exception=(err, catch_backtrace()) + end + end + end + + return proc +end + +# global Xvfb process for use by all containers +const xvfb_lock = ReentrantLock() +const xvfb_proc = Ref{Union{Base.Process,Nothing}}(nothing) + + +function installed_julia_dir(jp) + jp_contents = readdir(jp) + # Allow the unpacked directory to either be insider another directory (as produced by + # the buildbots) or directly inside the mapped directory (as produced by the BB script) + if length(jp_contents) == 1 + jp = joinpath(jp, first(jp_contents)) + end + jp +end + +function runner_sandboxed_julia(install::String, args=``; registries_dir, + storage_dir, + dot_julia_path = mktempdir(), + install_dir="/opt/julia", + stdin=stdin, stdout=stdout, stderr=stderr, + env::Dict{String,String}=Dict{String,String}(), + mounts::Dict{String,String}=Dict{String,String}(), + xvfb::Bool=true, cpus::Vector{Int}=Int[]) + julia_path = installed_julia_dir(install) + rootfs = prepare_rootfs() + read_only_maps = Dict( + "/" => rootfs.path, + install_dir => julia_path, + "/usr/local/share/julia/registries" => registries_dir, + ) + + artifacts_path = joinpath(storage_dir, "artifacts") + mkpath(artifacts_path) + read_write_maps = merge(mounts, Dict( + joinpath(rootfs.home, ".julia/artifacts") => artifacts_path, + joinpath(rootfs.home, ".julia") => dot_julia_path + )) + + env = merge(env, Dict( + # use the provided registry + # NOTE: putting a registry in a non-primary depot entry makes Pkg use it as-is, + # without needingb to set Pkg.UPDATED_REGISTRY_THIS_SESSION. + "JULIA_DEPOT_PATH" => "::/usr/local/share/julia", + + # some essential env vars (since we don't run from a shell) + "PATH" => "/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin", + "HOME" => rootfs.home, + )) + if haskey(ENV, "TERM") + env["TERM"] = ENV["TERM"] + end + + if xvfb + lock(xvfb_lock) do + if xvfb_proc[] === nothing || !process_running(xvfb_proc[]) + proc = Base.run(`Xvfb :1 -screen 0 1024x768x16`; wait=false) + sleep(1) + process_running(proc) || error("Could not start Xvfb") + + xvfb_proc[] === nothing && atexit() do + kill(xvfb_proc[]) + wait(xvfb_proc[]) + end + xvfb_proc[] = proc + end + end + + env["DISPLAY"] = ":1" + read_write_maps["/tmp/.X11-unix"] = "/tmp/.X11-unix" + end + + cmd = `$install_dir/bin/julia` + + # restrict resource usage + if !isempty(cpus) + cmd = `/usr/bin/taskset --cpu-list $(join(cpus, ',')) $cmd` + env["JULIA_CPU_THREADS"] = string(length(cpus)) # JuliaLang/julia#35787 + end + + # NOTE: we use persist=true so that modifications to the rootfs are backed by + # actual storage on the host, and not just the (1G hard-coded) tmpfs, + # because some packages like to generate a lot of data during testing. + + config = SandboxConfig(read_only_maps, read_write_maps, env; + rootfs.uid, rootfs.gid, pwd=rootfs.home, persist=true, + stdin, stdout, stderr, verbose=isdebug(:sandbox)) + + return config, `$cmd $args` +end + +end # module diff --git a/test/runtests.jl b/test/runtests.jl new file mode 100644 index 0000000..688e2b4 --- /dev/null +++ b/test/runtests.jl @@ -0,0 +1,9 @@ +using Test, SandboxJulia + +script = """ + using Pkg; + Pkg.add("Example") + using Example + """ + +run_sandboxed_julia(`-e $script`; privileged=true)