Skip to content

Source bundling

When you write python -m hello as your entrypoint, the VM needs to find hello/ somewhere on disk. mvmforge puts it there.

Per ADR-0008, v0.1 implements bake-into-rootfs source bundling. At compile time, the user’s source tree is copied into the artifact directory; the generated flake builds it as a Nix derivation; mkGuest’s preStart hook symlinks the package output to the entrypoint’s working_dir at boot.

The pipeline

app.py declares source = local_path("src")
mvmforge compile reads app.source.path = "src" (relative to manifest dir)
walks the tree, applies include/exclude globs, copies files
deterministically into <artifact>/src/
generated flake.nix:
appPkg = pkgs.stdenv.mkDerivation {
pname = "<id>-app";
src = ./src;
installPhase = "mkdir -p $out && cp -R . $out/";
};
mkGuest packages = [ appPkg ] ++ imagePackages
services.<id>.preStart symlinks ${appPkg} → ${working_dir}
services.<id>.command does: cd ${working_dir}; exec <command>
VM boots, your code is at /app, python -m hello finds it

What’s in the artifact

<artifact-dir>/
flake.nix # references ./src
launch.json # carries source.tree_hash
src/ # your code, deterministically copied

launch.json’s source field describes the result:

{
"source": {
"kind": "local_path",
"subdir": "src",
"file_count": 42,
"tree_hash": "0539ab1327c962939c92c43069475f74fccf4f01320534d2a08d755d2a7e9b61"
}
}

tree_hash is content-addressed: same bytes in, same hash out, regardless of mtime, machine, or user. The exact byte format is pinned in ADR-0008 §6.

Globs

app.source.include and app.source.exclude filter which files are copied. Defaults: include = ["**"], exclude = []. Glob syntax is the standard recursive form:

PatternMatches
**Zero or more path segments.
*A single segment with no /.
?A single character with no /.
[abc]One of the listed characters.

A path is included iff it matches at least one include pattern AND no exclude pattern. Patterns match against the path relative to source.path, with forward-slash separators on every host OS.

mv.app(
name="my-app",
source=mv.local_path(
".",
include=["**/*.py", "requirements.txt"],
exclude=["**/__pycache__/**", "tests/**", ".git/**"],
),
...
)

.git/ is unconditionally excluded regardless of user globs, as a safety default.

Symlinks in the source tree are subject to a safety rule:

  • In-tree symlinks (resolved target lies within source.path) are preserved verbatim. Their relative target string is recorded in tree_hash.
  • Out-of-tree symlinks (absolute paths or targets that climb above source.path after canonicalization) are rejected with E_SOURCE_OUT_OF_TREE_SYMLINK. This prevents the rootfs from inadvertently inheriting bytes the user did not intend (e.g., a stray link -> /etc/passwd).

If you need to vendor an external dependency, copy it into the source tree explicitly.

Devices, sockets, FIFOs

Skipped silently. They don’t have meaningful contents inside a microVM rootfs, and recording them in tree_hash would be a portability hazard.

Reproducibility

The source copy is deterministic:

  • File entries are visited in lexicographic order (sorted at every directory level).
  • File mtimes are normalized to the Unix epoch when written. Nix uses content + mode for derivation hashing, not mtime, so this is safe.
  • File modes are preserved exactly.
  • Directory creation is lazy: directories are only created in <artifact>/src/ when a leaf inside survives the filters. Empty source dirs are not preserved.

Result: same source contents + same IR + same toolchain version → byte-identical artifact directory, including tree_hash.

What working_dir means now

Before bundling: working_dir was an absolute path inside the rootfs that the entrypoint cd’d to. With bundling, it still is — the difference is that the bundled source is now at working_dir at boot, via the preStart symlink. The IR field doesn’t change semantics; the rootfs just has more in it.

Known issue: rootfs writability

The preStart symlink is created via ln -sfn, which requires working_dir’s parent to be writable at boot time. mkGuest’s populateImageCommands pre-create /var, /tmp, /run, /home, /root, /mnt/* but not /app. If your working_dir falls outside the writable set and mkGuest mounts the rootfs read-only, preStart may fail.

For v0.1, the workaround is to choose a working_dir under /var/lib/<workload-id>/ or another path under one of the pre-created writable trees. ADR-0008 carries this as a known issue with a documented deferred fix (smart-default location).

Out of scope for v0.1

  • Mount-at-launch. A future ADR-0010 may add mvmctl --volume passthrough so users get instant code-change reload without recompiling. Not present in v0.1.
  • Language service factories (mkPythonService / mkNodeService). v0.1 emits plain mkGuest regardless of the runtime; language-typed factories with pythonPackages / port etc. await ADR-0009.
  • Non-local_path source kinds. nix_derivation and oci_image are reserved variants in the schema but rejected at validation.