@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.
@@ -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',