@spunto/build 0.16.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -4
- package/package.json +22 -2
- package/src/agent-connection/index.ts +347 -0
- package/src/agent-protocol/index.ts +190 -0
- package/src/commands/index.ts +205 -0
- package/src/index.ts +3 -0
- package/src/sandbox-agent/agent.mjs +1174 -0
- package/src/sandbox-agent/bootstrap.sh +64 -0
- package/src/sandbox-agent/index.ts +31 -0
- package/src/sandbox-agent/recipe.ts +65 -0
- package/src/sandbox-agent/runner.sh +38 -0
- package/src/script/setup-script.ts +8 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Spunto sandbox bootstrap (RFC 0035) — the main process of every sandbox, whatever runs it.
|
|
3
|
+
#
|
|
4
|
+
# Finds (or fetches) a Node >= 22, fetches the agent from the platform, and runs it forever. The
|
|
5
|
+
# provider only has to run `curl -fsSL $SPUNTO_API_URL/api/sandboxes/bootstrap.sh | sh` with
|
|
6
|
+
# SPUNTO_API_URL and SPUNTO_SANDBOX_TOKEN in the environment. Needs curl or wget, and glibc if the
|
|
7
|
+
# image has no suitable node (the official Node binaries are not built for musl).
|
|
8
|
+
#
|
|
9
|
+
# Shipped in @spunto/build (`./sandbox-agent`), so any control plane can serve it (RFC 0047):
|
|
10
|
+
# SPUNTO_AGENT_URL where the agent is fetched (default $SPUNTO_API_URL/api/sandboxes/agent.mjs)
|
|
11
|
+
# SPUNTO_NODE a Node >= 22 the control plane mounted in the box: nothing is downloaded
|
|
12
|
+
set -eu
|
|
13
|
+
|
|
14
|
+
API="${SPUNTO_API_URL:?SPUNTO_API_URL is required}"
|
|
15
|
+
: "${SPUNTO_SANDBOX_TOKEN:?SPUNTO_SANDBOX_TOKEN is required}"
|
|
16
|
+
NODE_VERSION="${SPUNTO_NODE_VERSION:-v24.11.1}"
|
|
17
|
+
AGENT_URL="${SPUNTO_AGENT_URL:-$API/api/sandboxes/agent.mjs}"
|
|
18
|
+
|
|
19
|
+
DIR=/opt/spunto-sandbox
|
|
20
|
+
if ! mkdir -p "$DIR" 2>/dev/null || [ ! -w "$DIR" ]; then
|
|
21
|
+
DIR="${HOME:-/tmp}/.spunto-sandbox"
|
|
22
|
+
mkdir -p "$DIR"
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
fetch() {
|
|
26
|
+
if command -v curl >/dev/null 2>&1; then curl -fsSL --retry 5 --retry-delay 2 "$1" -o "$2"
|
|
27
|
+
elif command -v wget >/dev/null 2>&1; then wget -qO "$2" "$1"
|
|
28
|
+
else echo "[bootstrap] neither curl nor wget in this image" >&2; exit 1
|
|
29
|
+
fi
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
node_ok() {
|
|
33
|
+
[ -n "$1" ] && "$1" -e 'process.exit(+process.versions.node.split(".")[0] >= 22 ? 0 : 1)' 2>/dev/null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
NODE="${SPUNTO_NODE:-}"
|
|
37
|
+
node_ok "$NODE" || NODE="$(command -v node 2>/dev/null || true)"
|
|
38
|
+
if ! node_ok "$NODE"; then
|
|
39
|
+
NODE="$DIR/node/bin/node"
|
|
40
|
+
if ! node_ok "$NODE"; then
|
|
41
|
+
case "$(uname -m)" in
|
|
42
|
+
x86_64|amd64) ARCH=x64 ;;
|
|
43
|
+
aarch64|arm64) ARCH=arm64 ;;
|
|
44
|
+
*) echo "[bootstrap] unsupported arch $(uname -m)" >&2; exit 1 ;;
|
|
45
|
+
esac
|
|
46
|
+
echo "[bootstrap] fetching node $NODE_VERSION ($ARCH)"
|
|
47
|
+
fetch "https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-linux-$ARCH.tar.gz" "$DIR/node.tgz"
|
|
48
|
+
rm -rf "$DIR/node" && mkdir -p "$DIR/node"
|
|
49
|
+
tar -xzf "$DIR/node.tgz" -C "$DIR/node" --strip-components=1
|
|
50
|
+
rm -f "$DIR/node.tgz"
|
|
51
|
+
fi
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
# Re-fetched on every boot, so a restart picks up a new agent without rebuilding anything.
|
|
55
|
+
while true; do
|
|
56
|
+
if fetch "$AGENT_URL" "$DIR/agent.mjs.new"; then
|
|
57
|
+
mv "$DIR/agent.mjs.new" "$DIR/agent.mjs"
|
|
58
|
+
fi
|
|
59
|
+
if [ -f "$DIR/agent.mjs" ]; then
|
|
60
|
+
"$NODE" "$DIR/agent.mjs" || true
|
|
61
|
+
fi
|
|
62
|
+
echo "[bootstrap] agent exited, restarting in 3s" >&2
|
|
63
|
+
sleep 3
|
|
64
|
+
done
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-box agent (RFC 0035, RFC 0047): the files a control plane serves to a box so that the box
|
|
3
|
+
* dials it — never imported, never read by this package.
|
|
4
|
+
*
|
|
5
|
+
* - `bootstrap.sh` — the box's main process: finds (or fetches) a Node >= 22, fetches the agent,
|
|
6
|
+
* runs it forever. `SPUNTO_AGENT_URL` says where from (default
|
|
7
|
+
* `$SPUNTO_API_URL/api/sandboxes/agent.mjs`), `SPUNTO_NODE` hands it a Node so it fetches none.
|
|
8
|
+
* - `agent.mjs` — one file, no dependency, served as is. It dials `SPUNTO_AGENT_CONNECT_URL`
|
|
9
|
+
* (default `$SPUNTO_API_URL/api/sandbox-agent/connect`) and speaks `./agent-protocol`.
|
|
10
|
+
* - `runner.sh` — the project runner: the recipe once per bake key, the features' environment, then
|
|
11
|
+
* the worker script. Handed to the agent in the plan, and run by a build in `bake-only` mode.
|
|
12
|
+
*
|
|
13
|
+
* What a consumer gets here is *where* the files are; reading and serving them is its own I/O. The
|
|
14
|
+
* same files are reachable as subpaths of the package (`@spunto/build/sandbox-agent/agent.mjs`…).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const SANDBOX_AGENT_FILES = {
|
|
18
|
+
agent: new URL("./agent.mjs", import.meta.url),
|
|
19
|
+
bootstrap: new URL("./bootstrap.sh", import.meta.url),
|
|
20
|
+
runner: new URL("./runner.sh", import.meta.url),
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
export type SandboxAgentFile = keyof typeof SANDBOX_AGENT_FILES
|
|
24
|
+
|
|
25
|
+
/** What each file is served as. */
|
|
26
|
+
export const SANDBOX_AGENT_CONTENT_TYPES: Record<SandboxAgentFile, string> = {
|
|
27
|
+
agent: "text/javascript; charset=utf-8",
|
|
28
|
+
bootstrap: "text/x-shellscript; charset=utf-8",
|
|
29
|
+
runner: "text/x-shellscript; charset=utf-8",
|
|
30
|
+
}
|
|
31
|
+
export * from "./recipe"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project's recipe as a box that carries the agent runs it (RFC 0042, RFC 0047): through the
|
|
3
|
+
* agent's own runner, in `bake-only` mode, so that what a build freezes into an image is exactly
|
|
4
|
+
* what a box would have baked in place — the `.baked-<key>` marker and the features' captured
|
|
5
|
+
* environment included. A box started from the result skips straight to the worker script.
|
|
6
|
+
*
|
|
7
|
+
* The runner's text is the caller's to read (`SANDBOX_AGENT_FILES.runner`): this package does no I/O.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { IMAGE_RECIPE_VERSION } from "../script/index"
|
|
11
|
+
|
|
12
|
+
/** Where the agent keeps a project (`<dir of agent.mjs>/project`, the bootstrap putting it in /opt/spunto-sandbox). */
|
|
13
|
+
export const SANDBOX_PROJECT_DIR = "/opt/spunto-sandbox/project"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The key a recipe is baked under: changes whenever the recipe would bake something different
|
|
17
|
+
* (the generator's version, the base image, the script). **A format**: a builder and the agent of
|
|
18
|
+
* a box started from its result must compute the same one, or the box bakes again.
|
|
19
|
+
*/
|
|
20
|
+
export async function recipeBakeKey(p: { image: string; script: string; recipeVersion?: number | string }): Promise<string> {
|
|
21
|
+
const text = `${p.recipeVersion ?? IMAGE_RECIPE_VERSION}\n${p.image}\n${p.script}`
|
|
22
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text))
|
|
23
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 16)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const shq = (v: string) => `'${v.replace(/'/g, `'\\''`)}'`
|
|
27
|
+
|
|
28
|
+
/** An image's `ENV` as shell: its `PATH` in front of the one there is, the rest only where unset. */
|
|
29
|
+
export function imageEnvExports(env: Record<string, string> | null | undefined): string[] {
|
|
30
|
+
return Object.entries(env ?? {})
|
|
31
|
+
.filter(([k]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k))
|
|
32
|
+
.map(([k, v]) => (k === "PATH" ? `export PATH=${shq(v)}"\${PATH:+:$PATH}"` : `[ -n "\${${k}+x}" ] || export ${k}=${shq(v)}`))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** FNV-1a, for heredoc tags: a tag only has to be absent from the body it fences. */
|
|
36
|
+
function tagHash(s: string): string {
|
|
37
|
+
let h = 0x811c9dc5
|
|
38
|
+
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193) >>> 0
|
|
39
|
+
return h.toString(16).padStart(8, "0")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What a builder runs, as root, in a container of the project's image: the recipe through the
|
|
44
|
+
* runner in `bake-only` mode. Self-contained: nothing to fetch, no token, nothing personal.
|
|
45
|
+
* `imageEnv`: the image's `ENV`, for a builder booted from its files without it (a microVM).
|
|
46
|
+
*/
|
|
47
|
+
export function buildRecipeScript(recipe: { bakeKey: string; bake: string; runner: string }, imageEnv: Record<string, string> | null = null): string {
|
|
48
|
+
if (!/^[A-Za-z0-9_-]+$/.test(recipe.bakeKey)) throw new Error(`Invalid bake key ${recipe.bakeKey}`)
|
|
49
|
+
const fence = (name: string, body: string) => {
|
|
50
|
+
const tag = `__SPUNTO_${name}_${tagHash(body)}__`
|
|
51
|
+
return `cat > "$DIR/${name.toLowerCase()}.sh" <<'${tag}'\n${body.replace(/\n?$/, "\n")}${tag}\n`
|
|
52
|
+
}
|
|
53
|
+
return [
|
|
54
|
+
"#!/bin/bash",
|
|
55
|
+
"set -eu",
|
|
56
|
+
`DIR=${SANDBOX_PROJECT_DIR}`,
|
|
57
|
+
...imageEnvExports(imageEnv),
|
|
58
|
+
'mkdir -p "$DIR" && chmod 700 "$DIR"',
|
|
59
|
+
fence("BAKE", recipe.bake),
|
|
60
|
+
fence("RUNNER", recipe.runner),
|
|
61
|
+
'chmod 700 "$DIR/bake.sh" "$DIR/runner.sh"',
|
|
62
|
+
`exec bash "$DIR/runner.sh" "$DIR" ${recipe.bakeKey} bake-only`,
|
|
63
|
+
"",
|
|
64
|
+
].join("\n")
|
|
65
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# spunto-project-runner — the recipe once per bake key, then the worker script (setup once, start always)
|
|
3
|
+
#
|
|
4
|
+
# One copy, two users: the in-sandbox agent (handed it in the project plan, `plan.runner`) and a
|
|
5
|
+
# provider's build (`buildRecipeScript` in sandbox-project.ts, mode `bake-only`) — so what a build
|
|
6
|
+
# freezes is exactly what a sandbox would have baked in place, marker and features' env included.
|
|
7
|
+
DIR="$1"; KEY="$2"; MODE="$3"
|
|
8
|
+
if [ ! -f "$DIR/.baked-$KEY" ]; then
|
|
9
|
+
echo "[spunto] baking the project recipe ($KEY)"
|
|
10
|
+
bash "$DIR/bake.sh" || { echo "[spunto] recipe failed"; exit 90; }
|
|
11
|
+
touch "$DIR/.baked-$KEY"
|
|
12
|
+
fi
|
|
13
|
+
# Features declare their environment (containerEnv: nvm's PATH…) for the image; a recipe run in
|
|
14
|
+
# place leaves it only in /etc/bash.bashrc, which non-interactive shells skip. Keep what an
|
|
15
|
+
# interactive shell adds over a login one, for the lifecycle commands below and for the agent.
|
|
16
|
+
_skip='^(USER|HOME|LOGNAME|MAIL|PS1|PS2|PS4|SHLVL|_|PWD|OLDPWD|TERM|COLUMNS|LINES|HOSTNAME|BASH_.*|HIST.*|SHELLOPTS|BASHOPTS|LS_COLORS)$'
|
|
17
|
+
# Our own profile.d file must be out of the way, or from the second boot on the login shell
|
|
18
|
+
# already has what we are measuring and the difference comes out empty (seen for real).
|
|
19
|
+
rm -f /etc/profile.d/spunto-features.sh
|
|
20
|
+
env -i HOME=/root PATH="$PATH" bash -lc 'env -0' > "$DIR/.env.login" 2>/dev/null || true
|
|
21
|
+
env -i HOME=/root PATH="$PATH" PS1='$ ' bash -ic 'env -0' > "$DIR/.env.interactive" 2>/dev/null || true
|
|
22
|
+
: > "$DIR/env.list.new"; : > "$DIR/env.sh.new"
|
|
23
|
+
while IFS= read -r -d '' _kv; do
|
|
24
|
+
_k="${_kv%%=*}"; [[ "$_k" =~ $_skip ]] && continue
|
|
25
|
+
grep -qzxF -- "$_kv" "$DIR/.env.login" && continue
|
|
26
|
+
printf '%s\0' "$_kv" >> "$DIR/env.list.new"
|
|
27
|
+
printf 'export %s=%q\n' "$_k" "${_kv#*=}" >> "$DIR/env.sh.new"
|
|
28
|
+
done < "$DIR/.env.interactive"
|
|
29
|
+
mv -f "$DIR/env.list.new" "$DIR/env.list"; mv -f "$DIR/env.sh.new" "$DIR/env.sh"
|
|
30
|
+
# World-readable (no secret in it): login shells read it from profile.d, and BASH_ENV — which su
|
|
31
|
+
# keeps, unlike PATH — makes the non-interactive bash of postCreate/postStart read it too.
|
|
32
|
+
install -m 644 "$DIR/env.sh" /etc/profile.d/spunto-features.sh
|
|
33
|
+
export BASH_ENV=/etc/profile.d/spunto-features.sh
|
|
34
|
+
. "$DIR/env.sh"
|
|
35
|
+
# A build stops here: what is on disk now is what the provider freezes.
|
|
36
|
+
if [ "$MODE" = "bake-only" ]; then echo "[spunto] recipe baked"; exit 0; fi
|
|
37
|
+
echo "[spunto] running the worker script"
|
|
38
|
+
exec bash "$DIR/worker.sh"
|
|
@@ -1132,10 +1132,15 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
|
|
|
1132
1132
|
// `project.dind` makes DinD available without the docker-in-docker feature (baked into the base
|
|
1133
1133
|
// image, see images/worker-base) — treat it like the feature for entrypoint start + docker wait.
|
|
1134
1134
|
const hasDinDFeature = (project.features?.some((f) => f.id === "docker-in-docker") ?? false) || !!project.dind
|
|
1135
|
+
// `_MP_EP_STARTED` tells the every-start block of the same run (buildWorkerScript inlines both in
|
|
1136
|
+
// one shell) not to start them a second time: the docker-in-docker feature's entrypoint doesn't
|
|
1137
|
+
// check for a running daemon, and a second dockerd fails on the volume store lock — after having
|
|
1138
|
+
// replaced the first one's socket, leaving Docker unreachable for postStart and everything after.
|
|
1135
1139
|
if (project.postCreateCommand && ((project.features && project.features.length > 0) || project.dind)) {
|
|
1136
1140
|
push(
|
|
1137
1141
|
"",
|
|
1138
1142
|
"if [ -f " + FEATURE_ENTRYPOINTS_FILE + " ]; then",
|
|
1143
|
+
" _MP_EP_STARTED=1",
|
|
1139
1144
|
' echo "--- Starting feature entrypoints for postCreate ---"',
|
|
1140
1145
|
" while IFS= read -r _ep; do",
|
|
1141
1146
|
' if [ -f "$_ep" ]; then',
|
|
@@ -1299,9 +1304,11 @@ export function buildStartScript(params: StartScriptParams): { script: string; h
|
|
|
1299
1304
|
}
|
|
1300
1305
|
|
|
1301
1306
|
// ── 2. Feature entrypoints ────────────────────────────────────────────────
|
|
1307
|
+
// Skipped when the first-start setup of this same run already started them for postCreate
|
|
1308
|
+
// (`_MP_EP_STARTED`, see buildSetupScript §6).
|
|
1302
1309
|
push(
|
|
1303
1310
|
"",
|
|
1304
|
-
"if [ -f " + FEATURE_ENTRYPOINTS_FILE + " ]; then",
|
|
1311
|
+
"if [ -f " + FEATURE_ENTRYPOINTS_FILE + " ] && [ -z \"${_MP_EP_STARTED:-}\" ]; then",
|
|
1305
1312
|
' echo "--- Starting feature entrypoints ---"',
|
|
1306
1313
|
" while IFS= read -r _ep; do",
|
|
1307
1314
|
' if [ -f "$_ep" ]; then',
|