on master help sign in
README.md+48−31
since splitsnapshots
unifiedsplit
@@ -5,8 +5,10 @@ A Nix flake that builds an **OCI image** (via
5Apple's [`container`](https://github.com/apple/container) CLI on macOS.
5Apple's [`container`](https://github.com/apple/container) CLI on macOS.
6
6
7It's a plain, single-process image — no NixOS, no systemd — defined in
7It's a plain, single-process image — no NixOS, no systemd — defined in
8−[`flake.nix`](./flake.nix). It runs `fish` by default; you choose what it ships
8+[`flake.nix`](./flake.nix). `c start` drops you into a
9+[`herdr`](https://herdr.dev) session whose panes use your host login shell
10+(detected and bundled by `c init`). You choose what else it ships via a
9−via a per-project `container.nix`.
11+per-project `container.nix`.
10
12
11## Prerequisites
13## Prerequisites
12
14
@@ -14,32 +16,43 @@ via a per-project `container.nix`.
14- Apple's [`container`](https://github.com/apple/container) CLI installed, with
16- Apple's [`container`](https://github.com/apple/container) CLI installed, with
15 services started: `container system start` (first run downloads a Linux
17 services started: `container system start` (first run downloads a Linux
16 kernel).
18 kernel).
17−- A **Linux builder** — the image *contents* are Linux binaries, which can't be
19+- A **Linux builder** *only if you use packages that aren't in the binary
18− built on macOS. Use a [remote builder][builders] or, on Apple Silicon, the
20+ cache*. The image is assembled entirely on macOS — the root filesystem is just
21+ a host-built symlink tree over the **substituted** Linux store paths — so a
22+ fully-cached package set needs no builder. Compiling a Linux package that
23+ `cache.nixos.org` doesn't have does, though: use a [remote builder][builders]
19− `nix-darwin` `linux-builder`. With one configured, `nix` dispatches Linux
24+ or, on Apple Silicon, the `nix-darwin` `linux-builder`.
20− builds to it automatically (most packages also come prebuilt from the binary
21− cache, so the builder is only needed for the uncached bits).
22
25
23[builders]: https://nix.dev/manual/nix/latest/advanced-topics/distributed-builds
26[builders]: https://nix.dev/manual/nix/latest/advanced-topics/distributed-builds
24
27
25## Packages
28## Packages
26
29
27−`init` builds the image from a **`container.nix`** in the current directory — a
30+`init` builds the image from a **`container.nix`** in the current directory — an
28−`{ pkgs, nur }` function returning the package list (`nur` is
31+attrset with a `packages` function (`{ pkgs, nur }` returning the package list;
29−[NUR](https://github.com/nix-community/NUR), for packages outside nixpkgs). It
32+`nur` is [NUR](https://github.com/nix-community/NUR), for packages outside
30−is **required**; there is no default set.
33+nixpkgs) and an optional `mounts` list. It is **required**; there is no default.
31
34
32```nix
35```nix
33# ./container.nix
36# ./container.nix
37+{
34−{ pkgs, nur }: with pkgs; [
38+ packages = { pkgs, nur }: with pkgs; [
35− fish # the default cmd is /bin/fish, so include it (or change config.cmd)
36− coreutils
37− git
39+ git
38− go
40+ go
39− nur.repos.charmbracelet.crush
41+ nur.repos.charmbracelet.crush
42+ ];
43+ # Extra "host:container" bind mounts, on top of $PWD → /workspace. A leading
44+ # ~/ in the host path expands to $HOME. Optional.
45+ mounts = [
46+ "~/data:/data"
40−]
47+ ];
48+}
41```
49```
42
50
51+Your **host login shell** (detected by `c init`, bash fallback), **coreutils**,
52+[**herdr**](https://herdr.dev) (the session `c start` launches), **Nix** itself
53+(with CA certs and `nix-command`/`flakes` enabled, single-user), and the ncurses
54+**terminfo** database are always included — no need to list them.
55+
43Point at a different file with `-c`/`--config`:
56Point at a different file with `-c`/`--config`:
44
57
45```sh
58```sh
@@ -55,17 +68,17 @@ that holds a `container.nix`; the container is named after that directory.
55# build the image, load it, and create the container (named after $PWD):
68# build the image, load it, and create the container (named after $PWD):
56nix run github:ilyakooo0/nix-container -- init
69nix run github:ilyakooo0/nix-container -- init
57
70
58−# start it and attach — drops you into fish:
71+# start it and attach — drops you into a herdr session:
59nix run github:ilyakooo0/nix-container -- start
72nix run github:ilyakooo0/nix-container -- start
60```
73```
61
74
62- `init` builds the OCI archive in a temp dir, loads it into `container`,
75- `init` builds the OCI archive in a temp dir, loads it into `container`,
63 deletes the temp file, then (re)creates the container. It mounts the current
76 deletes the temp file, then (re)creates the container. It mounts the current
64− directory at `/workspace` (the container's working directory) and your
77+ directory at `/workspace` (the container's working directory).
65− `~/.config` at `/root/.config`.
66- `--ssh` forwards your host SSH agent socket into the container (so `git` over
78- `--ssh` forwards your host SSH agent socket into the container (so `git` over
67 SSH works with your keys).
79 SSH works with your keys).
80+- Add more mounts persistently via `mounts` in `container.nix` (above), or
68−- Add more mounts by passing them through (mounts can only be set at creation):
81+ ad-hoc by passing them through to `create` (mounts can only be set at creation):
69
82
70 ```sh
83 ```sh
71 nix run github:ilyakooo0/nix-container -- init -v $HOME/data:/data
84 nix run github:ilyakooo0/nix-container -- init -v $HOME/data:/data
@@ -86,18 +99,19 @@ nix run github:ilyakooo0/nix-container -- init
86## Customize further
99## Customize further
87
100
88Fork or clone to change what isn't per-project — `config.cmd`/`env` or the image
101Fork or clone to change what isn't per-project — `config.cmd`/`env` or the image
89−name — in [`flake.nix`](./flake.nix). The flake also exposes build helpers, both
102+name — in [`flake.nix`](./flake.nix). The flake also exposes build helpers:
90−taking a `{ pkgs, nur }: [ ... ]` function:
91
103
92−- `lib.<system>.mkImage` → an OCI image.
93−- `lib.<system>.copyWith` → a skopeo copy app, for building/pushing the image
104+- `lib.<system>.copyWith` → a skopeo copy app for a `{ pkgs, nur }: [ ... ]`
94− anywhere skopeo can write (e.g. a registry):
105+ function, for building/pushing the image anywhere skopeo can write (e.g. a
106+ registry):
95
107
96 ```sh
108 ```sh
97 drv=$(nix build --no-link --print-out-paths --impure --expr \
109 drv=$(nix build --no-link --print-out-paths --impure --expr \
98− '(builtins.getFlake "github:ilyakooo0/nix-container").lib.${builtins.currentSystem}.copyWith (import ./container.nix)')
110+ '(builtins.getFlake "github:ilyakooo0/nix-container").lib.${builtins.currentSystem}.copyWith (import ./container.nix).packages')
99 $drv/bin/copy-to docker://ghcr.io/me/img:latest
111 $drv/bin/copy-to docker://ghcr.io/me/img:latest
100 ```
112 ```
113+- `lib.<system>.mkImage <name> <shell>` → the underlying OCI image (`copyWith`
114+ defaults these to `nix-container` / `bash`).
101
115
102The flake uses `flake-utils` over `{aarch64,x86_64}-{darwin,linux}`; the host
116The flake uses `flake-utils` over `{aarch64,x86_64}-{darwin,linux}`; the host
103system maps to the matching Linux target automatically (an Intel Mac builds an
117system maps to the matching Linux target automatically (an Intel Mac builds an
@@ -105,9 +119,12 @@ system maps to the matching Linux target automatically (an Intel Mac builds an
105
119
106## How it works
120## How it works
107
121
108−- **Contents** are built from `pkgsLinux` → the Linux builder.
122+- **Contents** are Linux store paths — downloaded prebuilt from the binary cache
123+ (only built on a Linux builder if not cached).
124+- **Assembly** runs entirely on your Mac: `copyToRoot` is a *host-arch* `buildEnv`
125+ symlink tree over those Linux paths (symlinks are arch-neutral), and
109−- **Assembly** (`nix2container` + the `skopeo` copy) runs on your Mac, so a
126+ `nix2container` + `skopeo` package it — so for a cached set, `init` produces a
110− single `init` produces a loadable archive.
127+ loadable Linux image with no Linux build at all.
111- Apple's `container` runs each container in its own lightweight Linux VM and
128- Apple's `container` runs each container in its own lightweight Linux VM and
112 consumes standard OCI images — no `--privileged` or systemd needed.
129 consumes standard OCI images — no `--privileged` or systemd needed.
113
130
c+43−11
@@ -1,7 +1,7 @@
1#!/usr/bin/env fish
1#!/usr/bin/env fish
2# Manage a container named after the current directory.
2# Manage a container named after the current directory.
3# c init [-c FILE] [extra create args] build + load the image, then (re)create it
3# c init [-c FILE] [extra create args] build + load the image, then (re)create it
4−# (packages from FILE, default ./container.nix)
4+# (config from FILE, default ./container.nix)
5# c start start it and attach
5# c start start it and attach
6
6
7set -l name (path basename $PWD)
7set -l name (path basename $PWD)
@@ -22,17 +22,23 @@ switch $argv[1]
22 end
22 end
23
23
24 if not test -e $config
24 if not test -e $config
25− echo "c: package file not found: $config" >&2
25+ echo "c: config file not found: $config" >&2
26− echo " create one ('{ pkgs, nur }: [ ... ]') or pass -c FILE" >&2
26+ echo " create one ('{ packages = { pkgs, nur }: [ ... ]; mounts = [ ]; }') or pass -c FILE" >&2
27 exit 1
27 exit 1
28 end
28 end
29
29
30+ # Host login shell (by name): added to the image automatically and baked
31+ # in as the cmd + default $SHELL (what herdr panes etc. spawn), so it
32+ # needn't be listed in container.nix. Falls back to bash.
33+ set -l shell bash
34+ test -n "$SHELL"; and set shell (path basename $SHELL)
35+
30− # Build an image with exactly the packages from $config, into a temp
36+ # Build an image with the config's `packages` (plus the shell) into a
31− # dir, load it, then clean up.
37+ # temp dir, load it, then clean up.
32 set -l tmp (mktemp -d)
38 set -l tmp (mktemp -d)
33 set -l archive $tmp/image.tar.gz
39 set -l archive $tmp/image.tar.gz
34− set -lx NIX_CONTAINER_PKGS (path resolve $config)
40+ set -lx NIX_CONTAINER_CONFIG (path resolve $config)
35− set -l expr "(builtins.getFlake \"$flake\").lib.\${builtins.currentSystem}.copyWith (import (/. + builtins.getEnv \"NIX_CONTAINER_PKGS\"))"
41+ set -l expr "(builtins.getFlake \"$flake\").lib.\${builtins.currentSystem}.copyWithShell \"nix-container-$name\" \"$shell\" (import (/. + builtins.getEnv \"NIX_CONTAINER_CONFIG\")).packages"
36 set -l copyer (nix build --impure --no-link --print-out-paths --expr "$expr")
42 set -l copyer (nix build --impure --no-link --print-out-paths --expr "$expr")
37 set -l rc $status
43 set -l rc $status
38 if test $rc -eq 0
44 if test $rc -eq 0
@@ -47,14 +53,40 @@ switch $argv[1]
47 rm -rf $tmp
53 rm -rf $tmp
48 test $rc -eq 0; or exit $rc
54 test $rc -eq 0; or exit $rc
49
55
50− # Mount the project at /workspace (the default cwd) and host ~/.config.
56+ # Mount the project at /workspace (the default cwd), plus any extra
57+ # mounts from the config (`mounts = [ "host:container" … ]`). A leading
58+ # ~/ in the host path expands to $HOME (nix leaves it literal).
51 set -l mounts -v "$PWD:/workspace"
59 set -l mounts -v "$PWD:/workspace"
52− test -d $HOME/.config; and set -a mounts -v "$HOME/.config:/root/.config"
60+ for m in (nix eval --impure --raw --expr "builtins.concatStringsSep \"\n\" ((import (/. + builtins.getEnv \"NIX_CONTAINER_CONFIG\")).mounts or [])")
61+ set -a mounts -v (string replace -r '^~/' "$HOME/" -- $m)
62+ end
63+
64+ # TERM to bake into the container env (`-e TERM`): the runtime hands the
65+ # `start -ai` session a bare "xterm", so set the host's real terminal,
66+ # mapped to an entry the bundled ncurses ships — Ghostty reports
67+ # "xterm-ghostty", which ncurses ships as "ghostty" — falling back to
68+ # xterm-256color for an empty or bare-"xterm" value.
69+ set -l term xterm-256color
70+ if test "$TERM" = xterm-ghostty
71+ set term ghostty
72+ else if test -n "$TERM"; and test "$TERM" != xterm
73+ set term $TERM
74+ end
53
75
54− # Replace any existing container of the same name.
76+ # Replace any existing container of the same name, then create it running
77+ # herdr in a session named after the project (`<dir>-container`). This
78+ # overrides the image's default plain-herdr cmd; `c start` attaches to it.
55 container rm --force $name 2>/dev/null
79 container rm --force $name 2>/dev/null
80+ # The image only lays down /bin and /etc, so /tmp and /run don't exist;
81+ # without them anything that writes temp files (herdr, build tools, …)
82+ # fails with ENOENT. Mount writable tmpfs at both.
56− container create --name $name --ssh -it --cwd /workspace $mounts $argv $image
83+ container create --name $name --ssh -it -e "TERM=$term" \
84+ --tmpfs /tmp --tmpfs /run --memory 8g --cwd /workspace $mounts $argv \
85+ $image /bin/herdr session attach "$name-container"
57 case start
86 case start
87+ # Start the container and attach to its cmd (herdr). TERM was baked into
88+ # the container env at create time (`-e TERM`), which survives this path.
89+ # Exiting herdr (PID1) stops the container; `c start` boots it again.
58 container start -ai $name
90 container start -ai $name
59 case '*'
91 case '*'
60 echo "usage: c init [-c FILE] [create args] | start" >&2
92 echo "usage: c init [-c FILE] [create args] | start" >&2
flake.nix+96−45
@@ -72,55 +72,99 @@
72 # Host package set, for the wrapper app below.
72 # Host package set, for the wrapper app below.
73 pkgsHost = nixpkgs.legacyPackages.${system};
73 pkgsHost = nixpkgs.legacyPackages.${system};
74
74
75− # Name/tag baked into the image's OCI config.
75+ # Default image name (for `copyWith`); `c init` passes a per-directory
76+ # name (`nix-container-<dir>`) to `copyWithShell` instead.
76 imageName = "nix-container";
77 imageName = "nix-container";
77 imageTag = "latest";
78 imageTag = "latest";
78
79
79− # Build the OCI image from a `{ pkgs, nur }: [ ... ]` package function.
80+ # Build the OCI image (named `name`) from a `{ pkgs, nur }: [ ... ]`
80− # The default `cmd` is /bin/fish, so the package set should include
81+ # package function, for the named shell. The image's `cmd` is herdr
81− # `fish` (warned about below if missing) or override `config.cmd`.
82+ # (the session `c start` attaches to); the shell is the default `$SHELL`
83+ # (what herdr panes spawn) and is added automatically, so `container.nix`
84+ # lists only your extras. Nix (+ CA certs) and the ncurses terminfo
85+ # database are always shipped too. Nothing here is fish-specific: the
86+ # TERM fixup happens host-side in `c` (passed via `-e TERM` at create).
82 mkImage =
87 mkImage =
83− packages:
88+ name: shell: packages:
84− let
85− pkgList = packages {
86− pkgs = pkgsLinux;
87− nur = nurPkgs;
88− };
89− in
90− pkgsLinux.lib.warnIf (!builtins.any (p: (p.pname or "") == "fish") pkgList)
91− "nix-container: config.cmd is /bin/fish but the package set has no `fish` — the container will fail to start unless something provides /bin/fish (or change config.cmd)."
92− (
93− n2c.buildImage {
89+ n2c.buildImage {
94− name = imageName;
90+ inherit name;
95− tag = imageTag;
91+ tag = imageTag;
96− inherit arch;
92+ inherit arch;
97
93
98− # A plain, single-process root filesystem. No NixOS, no systemd.
94+ # A plain, single-process root filesystem. No NixOS, no systemd.
95+ # buildEnv only *symlinks* paths (never runs them), so build it on
96+ # the host arch — the tree is arch-neutral and links the
97+ # (substituted) Linux store paths, sparing the Linux builder.
99− copyToRoot = pkgsLinux.buildEnv {
98+ copyToRoot = pkgsHost.buildEnv {
100− name = "root";
99+ name = "root";
101− paths = pkgList;
100+ paths =
101+ packages {
102+ pkgs = pkgsLinux;
103+ nur = nurPkgs;
104+ }
105+ # The shell (cmd + $SHELL), when it exists in nixpkgs.
106+ ++ pkgsLinux.lib.optional (pkgsLinux ? ${shell}) pkgsLinux.${shell}
107+ ++ [
108+ pkgsLinux.nix
109+ pkgsLinux.cacert
110+ # Basic userland (ls, cat, cp, sleep, …) so the container is
111+ # usable out of the box and tools that shell out don't fail.
112+ pkgsLinux.coreutils-full
113+ # Networking glue glibc needs: /etc/nsswitch.conf (+passwd,
114+ # group) so DNS/host lookups resolve, and /etc/protocols,
115+ # /etc/services. Without these, hostname resolution fails.
116+ pkgsLinux.fakeNss
117+ pkgsLinux.iana-etc
118+ # The terminfo database (+ tput/clear/reset) so TUIs find the
119+ # entry for whatever TERM `c` passes in and render correctly.
120+ pkgsLinux.ncurses
121+ # The session `c start` always launches.
122+ pkgsLinux.herdr
123+ ];
124+ # Link all of /etc so the above files (nsswitch.conf, protocols,
125+ # services, passwd, group, ssl certs) are present.
102− pathsToLink = [ "/bin" ];
126+ pathsToLink = [
127+ "/bin"
128+ "/etc"
129+ ];
103− };
130+ };
104
131
105− initializeNixDatabase = true;
132+ initializeNixDatabase = true;
106
133
107− config = {
134+ config = {
108− # `cmd` (not `entrypoint`): it's the default command and is
135+ # `cmd` (not `entrypoint`): the default command, *replaced* by any
109− # *replaced* by `container run … -- <cmd>`. An entrypoint would
136+ # command passed to `container run/create`. Plain herdr here;
110− # be prepended instead, so `run … -- /bin/sh` would become
137+ # `c init` overrides it with `herdr session attach <dir>-container` so
111− # `/bin/bash /bin/sh` and fail with "cannot execute binary file".
138+ # the session is named per project. `c start` attaches via
139+ # `container start -ai`; exiting herdr (PID1) stops the container.
112− cmd = [ "/bin/fish" ];
140+ cmd = [ "/bin/herdr" ];
113− env = [
141+ env = [
114− "PATH=/bin"
142+ "PATH=/bin"
115− "HOME=/root"
143+ "HOME=/root"
116− ];
144+ # Default shell for tools that spawn one (herdr panes, etc.).
117− };
145+ "SHELL=/bin/${shell}"
146+ # Marker so shells/scripts can detect they're in here.
147+ "NIX_CONTAINER=1"
148+ # Advertise truecolor for 24-bit-aware tools; `c` passes the
149+ # resolved TERM via -e (the runtime would otherwise force xterm).
150+ "COLORTERM=truecolor"
151+ # A UTF-8 locale so tools handle multibyte/wide characters
152+ # correctly. glibc ships C.UTF-8 built in, so this needs no
153+ # locale-archive.
154+ "LANG=C.UTF-8"
155+ # CA trust for every TLS client (curl/git/…) and Nix.
156+ "SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt"
157+ "NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt"
158+ # Make the bundled Nix usable: flake commands on, and
159+ # single-user (no daemon/nixbld).
160+ "NIX_CONFIG=experimental-features = nix-command flakes\nbuild-users-group =\nsandbox = false"
161+ ];
118− }
162+ };
119− );
163+ };
120
164
121 # `nix run . -- init|start` runs the bundled `c` manager script. Its
165 # `nix run . -- init|start` runs the bundled `c` manager script. Its
122 # `init` builds the image from the project's `container.nix` (via
166 # `init` builds the image from the project's `container.nix` (via
123− # `lib.copyWith`); the script finds the flake from its own (store)
167+ # `lib.copyWithShell`); the script finds the flake from its own (store)
124 # location, so this works from a checkout or straight from
168 # location, so this works from a checkout or straight from
125 # `nix run github:…/nix-container`.
169 # `nix run github:…/nix-container`.
126 cApp = pkgsHost.writeShellScriptBin "c" ''
170 cApp = pkgsHost.writeShellScriptBin "c" ''
@@ -133,15 +177,22 @@
133 default = flake-utils.lib.mkApp { drv = cApp; };
177 default = flake-utils.lib.mkApp { drv = cApp; };
134 };
178 };
135
179
136− # Build helpers. `c init` uses `copyWith` to honour a per-project
180+ # Build helpers take a `{ pkgs, nur }: [ ... ]` packages function (the
137− # `container.nix`:
181+ # `packages` attr of a project's `container.nix`). `c init` uses
182+ # `copyWithShell` (host shell baked in); `copyWith` is the generic form:
138 # (builtins.getFlake "github:…/nix-container")
183 # (builtins.getFlake "github:…/nix-container")
139− # .lib.<system>.copyWith (import ./container.nix)
184+ # .lib.<system>.copyWith (import ./container.nix).packages
140 lib = {
185 lib = {
141 inherit mkImage;
186 inherit mkImage;
142− # skopeo copy app (`skopeo copy nix:<image> "$@"`) for an image built
187+ # Build the image's `copyTo` script (a skopeo-copy wrapper) from a
143− # from a `{ pkgs, nur }: [ ... ]` function.
188+ # `{ pkgs, nur }: [ ... ]` function, named `imageName`. Defaults the
189+ # shell (cmd + $SHELL) to bash; use `copyWithShell` to pick another.
144− copyWith = packages: (mkImage packages).copyTo;
190+ copyWith = packages: (mkImage imageName "bash" packages).copyTo;
191+ # Like `copyWith`, but for the given image `name` and shell — `c init`
192+ # passes `nix-container-<dir>` and the host login shell, which is added
193+ # to the image and used as cmd + $SHELL without being listed in
194+ # `container.nix`.
195+ copyWithShell = name: shell: packages: (mkImage name shell packages).copyTo;
145 };
196 };
146 }
197 }
147 );
198 );