zoryn devenv¶
zoryn devenv prepares an isolated environment for building and testing a package: it resolves the build dependencies (BuildRequires) and installs them for the target ALT branch, with the project directory bind-mounted read-write. In it you can:
- build and test the package under a specific scenario — branch, dependency set, tooling;
- edit code — on the host or right inside the environment — and re-run the build immediately;
- tailor the environment — pull in packages and external tools declaratively through config (
packages,build/build_usersteps, features), or install them on the fly withzoryn devenv install; - add packages from a test gyle build task (
--with-task-repo) or a builder's output repository (--with-builder-repo) and debug the build against them; - run LLM assistants (claude, codex, opencode, kimi, pi) isolated from the host.
The environment is cached and reused across runs.
Experimental
zoryn devenv is experimental. The interface and caching behaviour may change in future releases.
Usage¶
zoryn devenv # prepare (or reuse cached) env, drop into a shell
zoryn devenv --backend bwrap # use bubblewrap backend
zoryn devenv --backend podman # use podman backend
zoryn devenv --image registry.altlinux.org/p11/alt:latest # override the podman base image
zoryn devenv --profile p11 # use the [devenv.profiles.p11] config profile
zoryn devenv --branch p11 # one-shot branch override (no config edit)
zoryn devenv --backend podman -u root # podman only: enter as a specific user
zoryn devenv --rebuild # drop this env's container+image and recreate (reuses build cache)
zoryn devenv --rebuild --no-cache # the above, fully from scratch (podman build --no-cache)
zoryn devenv --no-build-deps # skip BuildRequires resolution (arch-restricted spec, built elsewhere)
zoryn devenv -s # silent: hide podman build/install output
zoryn devenv -- make check # run a command instead of an interactive shell
zoryn devenv -- rpmbuild -bi *.spec # run rpmbuild inside the env
zoryn devenv packages # list packages the env would install (no prepare)
zoryn resolves the package's BuildRequires: by macro-expanding the spec inside the prepared environment (two passes, see Dependency set), merges in any configured extra packages (see Configuration), and drops you into a shell in the current package directory. The project directory is mounted read-write, so edits made on the host are immediately visible inside.
The project directory is the git repository root: run zoryn devenv from any subdirectory (e.g. .gear/) and it operates on the repo root. Outside a git repository the current directory is used.
zoryn devenv -- CMD runs CMD without allocating a TTY (so it works in pipes and CI). An interactive shell is opened only when stdin is a terminal.
During preparation the podman build and dependency install stream their output live (dimmed); -v/--verbose passes it through verbatim, -s/--silent hides it behind stage markers. After the spec is expanded, the resolved BuildRequires are printed.
Backends¶
bwrap¶
Significantly more limited than podman
The bwrap/hasher backend only installs the spec's BuildRequires: into a chroot and bind-mounts the project. The podman-only capabilities — features, build/build_user image steps, a custom prompt, outbound_interface, dns, SSH-agent forwarding, --with-task-repo, and live zoryn devenv install — are not available here. (Extra mounts and --mount-bind are honoured on bwrap, bound at enter time.) Prefer podman for the full feature set; use bwrap only where podman is unavailable.
Uses bubblewrap over a freshly prepared hasher chroot. The package's BuildRequires: are installed via hasher into the chroot; the project directory is bind-mounted read-write on top. Re-entering a cached environment is lightweight — the chroot is reused without reinstalling packages.
git is always included in the bwrap chroot.
By default hasher initialises the chroot from the host apt configuration, so the bwrap backend does not automatically follow branch. Set [devenv].apt_config to a host apt config path — an apt.conf-format file, a sources.list file, or a directory containing either — and the chroot is populated from the repositories you choose (e.g. a p11 apt config to build against p11). hsh --apt-config itself only accepts an apt.conf-format file, so an apt.conf file is passed through as-is, while a sources.list is automatically wrapped into a generated apt.conf under ~/.cache/zoryn/devenv-aptconf/. The value is tilde-expanded and must exist; changing it rebuilds the chroot (it is part of the cache key). Machine config only (~/.zoryn, projects.d/). The podman backend also honours apt_config: it is used as the container's sources.list when no apt_builder or apt_sources override is set in the same config layer.
Requires bubblewrap and hasher.
podman¶
Set up rootless podman first
The podman backend runs containers rootless. Configure podman on the host beforehand (subuid/subgid ranges, storage, registries) per the ALT Linux guide: https://www.altlinux.org/Podman. Domain (LDAP/AD/SSSD) accounts in particular need explicit /etc/subuid and /etc/subgid entries (e.g. via usermod --add-subuids/--add-subgids) — without them rootless podman cannot build or run at all. zoryn additionally remaps an out-of-range host id to a fixed in-range id at build time and uses --userns=keep-id:uid=…,gid=… (podman 4.3+) so the container still maps back to your real id; see below.
The podman backend prepares an environment in two stages: it builds a small derived image once, then runs a persistent container from it that is reused on every later entry.
Image build. On first prepare, zoryn builds a derived image (zdevenv-<pkg>-<branch>:<key> — the package directory name, the branch, and a short cache-key hash) FROM an ALT base image (default registry.altlinux.org/<branch>/alt:latest, configurable via image in [devenv] or --image). Provisioning runs at build time as root, before any user-namespace remap — so apt's lock and cache directories are writable. The image:
- runs
apt-get update && apt-get dist-upgrade -y(mandatory: ALT base images are often stale); - installs the dependency set plus
sudo,bashandgit; - bakes in a user matching your host UID/GID, with passwordless
sudo. If your host id is out of range — above 60000, e.g. a domain (LDAP/AD/SSSD) account — the dev user is baked at a fixed in-range id (1000) instead, becauseuseradd(ALT tcb shadow) and a rootless build'schowncannot use an id outside yoursubuidrange.
Persistent container. zoryn then runs one container per cache key (named zdevenv-<pkg>-<branch>-<key>) with podman run -d --userns=keep-id … sleep infinity, the project bind-mounted read-write via -v:
- the keep-id uid remap happens once, at container creation; when the dev user was baked at a remapped id, zoryn uses
--userns=keep-id:uid=1000,gid=1000so your real host id still maps to it and bind-mounted files stay owned by you; - the image's
USERis the baked-in user, sopodman execenters as you (not root), and files you write to the bind-mounted project stay owned by you; - because the container persists, later entries and
install(whichexecinto the same container) are instant; - pass
--user/-u(podman only) to enter as someone else — e.g.-u rootor-u 1000:1000, mapped topodman exec --user.
Zombie reaping and the pid limit. The container runs with --init so a minimal PID 1 reaps zombies: the sleep infinity entrypoint would not, and build/test subprocesses (notably git's background auto-gc after commits) would otherwise accumulate as defunct processes and exhaust the pid limit. That cap is --pids-limit=<n> from [devenv].pids_limit (default 4096 — podman's own 2048 is low for parallel builds; 0 or a negative value means unlimited). The same value is applied as --ulimit nproc=<n>:<n>, because the cgroup cap alone does not lift podman's own per-user process rlimit (ulimit -u, around 512) — that is the limit a parallel build hits first, as posix_spawnp: Resource temporarily unavailable from gcc/as/lto1. The soft limit is only ever raised, never lowered below what the host already grants, and never above the host's hard limit (rootless podman cannot raise it). It is not part of the cache key, so a change takes effect only when the container is next recreated (e.g. with --rebuild).
Other details. The container gets a distinct --hostname (the package directory name) so its shell prompt is visibly different from the host. zoryn devenv install adds packages live into the running container as in-container root (podman exec --user root … apt-get install) — no image rebuild. --rebuild drops the container and image and recreates them from scratch (add --no-cache to also bust podman's build-layer cache; --no-cache on its own is only a build modifier and neither rebuilds nor deletes anything). The base image must be pullable (image / --image); pull or update it yourself when needed (e.g. podman pull registry.altlinux.org/sisyphus/alt:latest).
Custom image-build steps (build, build_user)¶
Two arrays of shell commands that become RUN lines in the generated Containerfile, run at image-build time:
buildruns as root — for system-level provisioning (compile/install a tool, drop a config file).build_userruns as the host user, in that user'sHOME— for per-user installers. A tool like claude'scurl … | bashinstalls into~/.local/bin, which would land in root's home (invisible to your shell) if run viabuild; put it inbuild_userso it lands in your home and is onPATHin the dev shell. Root steps here can use the passwordlesssudothat is set up.
Both run in the container's /bin/sh, are written verbatim, and are part of the cache key (changing either rebuilds the image). Your own steps sit in the image's project-specific tail, after the feature layers: first comes the base layer (bash, git, rpm-build), then one group of layers per feature — its packages, its run/run_user steps — and only then the project's own packages, your build steps (root), and finally your build_user steps. Feature layers are shared across projects by podman's layer cache, and each feature's layers survive changes to the features after it.
This is machine config only — accepted in ~/.zoryn and ~/.config/zoryn/projects.d/<project>.toml, but not in the committed .gear/devenv (warns as unknown). Build steps are a machine/developer choice and must not be executed from a committed repo file.
[devenv]
build = ["ln -s /usr/bin/python3 /usr/local/bin/python"] # as root
build_user = ["curl -fsSL https://claude.ai/install.sh | bash"] # as you
Requires podman.
Extra bind-mounts (mounts)¶
The mounts key in [devenv] is an array of host paths bind-mounted into the persistent container, in addition to the project directory (which is always mounted read-write). Each entry is one of:
- a bare host path — bound at the same path inside the container; or
- a
host:container[:opts]spec passed through topodman run -vunchanged (e.g./data:/data:ro).
Behaviour:
- A leading
~/~/is expanded to$HOMEon both sides of an entry (podman does not expand tildes itself; under--userns=keep-idthe container's home equals the host$HOME). The options field (e.g.ro) is left untouched. - Mounts become
-vflags at container-create time, so changing them recreates the container (the mount list is part of the cache key). - A mount source under
$HOMEthat does not yet exist is pre-created before the container starts — as an empty file if the name looks like a file (e.g.~/.claude.json), otherwise as a directory — so podman does not create a directory where a file is expected. - A read-only (
:ro) source is not auto-created if missing: it is treated as user-supplied input (a missing~/.vimrcis left absent rather than created empty). - A spec may reference
{devenv}(the environment's container name, as shown byzoryn devenv list; on bwrap, which has no container, thedevenv-<project>hostname),{project}(the project directory's basename),{branch}(the effective ALT branch) and{profile}(the active profile,defaultwhen none).{project}/{branch}/{profile}are expanded before anything else — the existence check, the auto-creation, the cache key.{devenv}is expanded late, once the container name is known (the name embeds the cache-key hash the mounts themselves feed), so the cache key carries the literal token; a config change that recreates the container also points{devenv}at a fresh directory — use{project}for one that survives recreations. One entry gives every environment its own host directory:mounts = ["~/.config/herdr/sessions/{devenv}"]. Unknown{tokens}are left as written. The placeholders work in config entries, in--mount-bindand in featuremounts.
This is machine config only — mounts is accepted in ~/.zoryn and ~/.config/zoryn/projects.d/<project>.toml, but not in the committed .gear/devenv (a mounts key there warns as unknown).
Both backends honour mounts. With podman the mounts are -v flags applied at container-create time. With bwrap they are bound into the chroot at enter time as --bind (or --ro-bind for a :ro spec), bound only if the host source exists; the $HOME-autocreate behaviour above is podman-only.
One-shot --mount-bind¶
To add a mount for a single run without editing the config, pass --mount-bind SPEC (repeatable). The SPEC uses the exact same syntax as a mounts entry, and the specs are appended after the configured ones. A relative host path is resolved against the current directory (after -C takes effect):
zoryn devenv --mount-bind /srv/data # rw, same path both sides
zoryn devenv --mount-bind ~/ref:~/ref:ro # read-only
zoryn devenv --mount-bind ../OpenUSD # relative → resolved to an absolute path
zoryn devenv --mount-bind /a:/a --mount-bind /b:/b # several at once
podman: changing mounts recreates the container
podman fixes bind-mounts at container creation — a mount cannot be attached to an already-created container. --mount-bind is therefore folded into the container cache key: a run whose mount set differs from the existing container's creates a new container from the cached image. This is cheap (image layers are reused), but anything changed inside the previous container's filesystem — outside the mounted paths — does not carry over. Repeating the run with the same --mount-bind set reuses the same container, and a run without the flag goes back to the previous one. Containers left behind by changed mounts are removed by zoryn devenv clean.
With bwrap the mount is bound at enter time, so it attaches to the existing prepared chroot without any rebuild.
podman: a mounted file replaced on the host goes stale
A bind mount of a single file is pinned to the file's inode. When a host-side tool rewrites that file by writing a temporary copy and renaming it over (how most configs are saved — ~/.claude.json included), the path on the host gets a new inode while the container keeps looking at the old one: the mounted file inside the devenv silently stops following the host. zoryn records the inodes of file mounts when the container is created and compares them on every enter — if a mounted file was replaced, it says so right before the shell opens and suggests zoryn devenv --rebuild to recreate the environment with the current files.
Custom shell prompt (prompt)¶
The prompt key in [devenv] is a literal bash PS1 string baked into the derived image: it is appended to the host user's ~/.bashrc at image-build time, so the interactive podman exec … bash shell uses it. The value is stored verbatim (single-quoted internally), so the usual prompt escapes — \u (user), \h (host), \w (working dir), and colour sequences like \[\e[32m\]…\[\e[0m\] — are interpreted by bash at prompt time. Changing it rebuilds the image (the prompt is part of the cache key).
This is machine config only — prompt is accepted in ~/.zoryn and ~/.config/zoryn/projects.d/<project>.toml, but not in the committed .gear/devenv (a prompt key there warns as unknown). It applies to the podman backend only.
Requires podman.
Container timezone (timezone, --timezone ZONE)¶
The podman container keeps the base image's timezone (UTC) by default. Set an IANA zone to have podman run --tz configure TZ and /etc/localtime inside the container — handy for debugging timezone-sensitive code:
The flag wins over the config; a per-project ~/.config/zoryn/projects.d/<project>.toml value wins over the global ~/.zoryn one. Machine config only — timezone is not read from the committed .gear/devenv. Podman backend only — bwrap inherits the host TZ.
podman: changing the zone recreates the container
--tz is fixed at container creation. The effective zone is compared against the one recorded on the existing container (zoryn.timezone label); a mismatch recreates the container from the cached image. A later run without --timezone recreates it back with the configured zone.
Outbound interface (outbound_interface, --outbound-interface IFACE)¶
The outbound_interface key in [devenv] is the name of a host network interface (e.g. eth1, wg0, eth0.10). When set, the container is switched off the default --network host onto a rootless pasta network namespace whose outbound traffic is forced out that interface via --outbound-if4/--outbound-if6 (pasta binds its host-side sockets to the interface with SO_BINDTODEVICE), so all the container's outgoing connections egress through it regardless of the host routing table. Only the address families present on both the interface and the template are bound — an IPv4-only interface such as a WireGuard wg0 does not drag in a failing IPv6 binding, and a family the template cannot serve (e.g. IPv6 when the default-route interface has no global IPv6) is dropped, since pasta rejects it with External interface not usable. When unset, the default --network host is kept. The same --network is used for the image build too, so apt at build time also goes out the chosen interface.
The interface is bound for egress only, not used as pasta's template: point-to-point / tun / WireGuard interfaces have no usable L2 and cannot be a template (External interface not usable). Instead the host's default-route interface is passed as the template (-i), so the container mirrors a normal interface's addresses/routes while its traffic leaves via the chosen interface. The chosen interface must have at least one usable IP address, and a host default route must exist, or container creation fails with a clear error. Resulting argument shape: --network pasta:-i,<default-iface>,--outbound-if4,<iface>.
If the chosen interface carries only one address family, pasta is restricted to it (-4 or -6). This keeps the unused family out of the container and, crucially, prevents a leak: the template interface may have the other family, whose traffic would otherwise egress via the template's route instead of the bound interface. So an IPv4-only WireGuard wg0 yields an IPv4-only container with no IPv6 path at all.
Because this gives the container its own network namespace, host-localhost services are no longer shared as they are with --network host (a service the host exposes on 127.0.0.1 is not reachable from inside the container, and vice versa). Changing the value recreates the container from the cached image: the interface is part of the container cache key but not of the image tag, since it only selects the route the build fetches over. The previous container is kept, so switching back re-enters it; zoryn devenv clean --all drops the ones no longer needed. The name is validated to a safe charset (letters, digits, . _ @ -).
This is machine config only — outbound_interface is accepted in ~/.zoryn and ~/.config/zoryn/projects.d/<project>.toml, but not in the committed .gear/devenv (an outbound_interface key there warns as unknown). It applies to the podman backend only.
zoryn devenv --outbound-interface wg0 # one-shot, overrides the config for this run
zoryn devenv -I '' # one-shot: ignore the configured interface, use --network host
The flag wins over the config; a per-project ~/.config/zoryn/projects.d/<project>.toml value wins over the global ~/.zoryn one. Because the interface is part of the cache key, a run with a different -I enters a different environment (its own image and container) rather than recreating the configured one — zoryn devenv clean without the flag leaves it behind, so remove it with zoryn devenv clean --all.
Requires podman (and a pasta-capable podman; pasta is the rootless default in current podman).
Resolver settings (dns, dns_search, dns_option)¶
Three array keys in [devenv] mirror the three /etc/resolv.conf fields and map to podman's own flags: dns → --dns (nameserver addresses, IPv4 or IPv6), dns_search → --dns-search (search domains), dns_option → --dns-option (resolver options such as ndots:2). Each accepts several entries and keeps their order. They apply to both the image build and the container, so apt resolves names at build time as well as inside the running environment. With none of them set, podman's own resolution is left alone: the container inherits the host resolvers.
The three keys are layer-select, like apt_sources and timezone and unlike mounts: the matching one-shot flag (--dns ADDR, --dns-search DOMAIN, --dns-option OPT, each repeatable) wins over the per-project projects.d/<project>.toml, which in turn wins over the global ~/.zoryn — the layer that sets a key replaces it rather than appending to it. Entry order is significant (the first nameserver is queried first), so a project has to be able to put its own resolver ahead of the machine-wide one. Each key picks its layer independently: a project may override dns while still inheriting the global dns_search.
This is what you need when the container cannot reach the host's resolvers — most often together with outbound_interface. A WireGuard wg0 binding forces DNS out the tunnel, where the host's own resolvers do not answer; adding a nameserver reachable over the tunnel (or a public one) fixes resolution without putting a DNS = line in the host's WireGuard config, which would change resolution for the whole host.
[devenv]
outbound_interface = "wg0"
dns = ["10.0.0.53", "8.8.8.8"]
dns_search = ["corp.example.com"]
dns_option = ["ndots:2"]
zoryn devenv --dns 10.0.0.53 # one run, one nameserver
zoryn devenv --dns 10.0.0.53 --dns 8.8.8.8 # several, in order
zoryn devenv --dns-search corp.example.com # search domain
zoryn devenv --dns-option ndots:2 # resolver option
Why a mounts entry for /etc/resolv.conf is not enough
Bind-mounting a resolver file (mounts = ["~/my-resolv.conf:/etc/resolv.conf:ro"]) only affects the container, because podman fixes bind-mounts at container creation. The image build is a separate step with no such mount, so apt-get update inside the Containerfile still fails with Temporary failure resolving 'ftp.altlinux.org'. The failure is easy to miss for a while: as long as the image stays in the layer cache the build step never runs, and the breakage only surfaces on the next rebuild (a changed packages/features/build set, or --rebuild). dns covers both steps.
Entries are validated: dns takes an IPv4 or IPv6 address (an IPv6 zone id such as fe80::1%eth0 is allowed) or the literal none — podman's marker for "leave the container without nameservers"; dns_search takes a domain, or . to empty the search list; dns_option takes a bare flag (rotate) or a name:value pair (ndots:2). Anything else is a configuration error.
These settings are not part of the cache key: reachability is not part of the environment's identity, so changing them must not orphan a container. Instead all three are recorded on the container as the zoryn.dns label, and a run whose settings differ recreates the container (image layers are reused) — both zoryn devenv and zoryn devenv install, which share the check with the SSH-agent socket, the timezone, the published ports and the env entries. If the image itself was built with the wrong nameservers — the apt-get update failure above — force a fresh build with --rebuild.
Machine config only (~/.zoryn, projects.d/) — not read from the committed .gear/devenv. Podman backend only; bwrap resolves through the host.
Filtered LAN access ([devenv.lan])¶
With outbound_interface set, the container is sealed off the LAN: every connection leaves through the chosen interface (typically a tunnel). The [devenv.lan] sub-table punches a filtered hole back — a second pasta interface (zoryn-lan0) attached to the container's namespace by a sidecar process, carrying routes to exactly the allow-listed destinations:
[devenv]
outbound_interface = "wg0"
[devenv.lan]
interface = "eth0"
allow = ["192.168.50.10", "192.168.50.0/24"]
Since zoryn 0.49.0 (the switch to the otoml parser) the sub-table can also be written inline — as a lan = { interface = "eth0", allow = ["192.168.50.10", "192.168.50.0/24"] } key inside the [devenv] block, placed before any [devenv.*] section header.
interface— the host LAN interface the sidecar binds its sockets to. Must differ fromoutbound_interface, and carry at least one usable address — when it does not (interface down or renamed), zoryn warns and enters without the LAN policy rather than refusing the shell.allow— IP addresses or CIDRs routed tointerface. A bare address means a/32host route. Host names are rejected by design — DNS keeps leaving through the tunnel, so no name resolution happens over the LAN path.
Routes are the entire filter: an allow-listed prefix leaves through the LAN interface, everything else keeps the default route whose socket is bound to outbound_interface and cannot reach the LAN. The filter applies only to connections the container initiates — inbound connections (say, to a published port) and their replies stay on the regular path, so a host inside an allowed subnet can reach the container too. Entries that would break the seal are refused up front: anything overlapping link-local (169.254.0.0/16, fe80::/10, which podman uses for the container's DNS and host access), anything covering the default route, and the host's own address.
The allow-list is runtime state, not container identity: editing it does not recreate the container — the routes are reconciled into the running one on the next zoryn devenv / enter / install. zoryn devenv inspect shows the live routes read from the namespace, not a stale label. Removing [devenv.lan] entirely tears the sidecar down on the next run.
Limitations, by design:
- Image build runs in its own namespace per step, so a LAN apt mirror is unreachable at build time.
- Filtering is per destination address; every port of an allowed address is reachable.
- No name resolution over the LAN path (see above).
- One host interface.
- The outbound/inbound split is by source address, so it holds for TCP but not for a UDP service bound to the wildcard address: its replies carry no source at routing time and still take the LAN path. A socket that binds to the container's own address before connecting out is the mirror case — it takes the regular path and does not reach the LAN.
- An allow-listed prefix wins over the container's own connected subnet: with
allow = ["10.0.0.0/8"]on a container addressed in10.88.5.0/24, connections to that subnet leave through the LAN interface too.
The routes live in their own routing table behind two policy rules, so ip route inside the container does not show them — use zoryn devenv inspect, or ip route show table 23205 and ip rule show in the namespace.
Machine config only — [devenv.lan] is accepted in ~/.zoryn and ~/.config/zoryn/projects.d/<project>.toml, never in the committed .gear/devenv, so a package cannot open a hole into your LAN. Podman backend only.
Publishing ports (ports)¶
A dev server inside the sealed container is reachable from the host browser via [devenv] ports:
Each entry is "port" (same port both sides) or "host:guest". Publishing rides podman's own pasta as -p 127.0.0.1:host:guest, so http://localhost:18080 on the host reaches the container's port 8080. Host ports are probed before the cached container is touched — a taken port fails with devenv.ports: host port 18080 is already in use … instead of a podman error, without discarding the environment you already had and without leaving a half-created one behind. A port the current container itself publishes does not count as taken: it is about to release it.
Publishing beyond the loopback¶
By default a published port is bound on 127.0.0.1 — reachable from the host, from nowhere else. Prefix an entry with an interface name or an address to publish it wider (localhost is accepted as a synonym for 127.0.0.1):
[devenv]
ports = [
"9229", # 127.0.0.1:9229 — the default
"eth0:8080", # the host's address on eth0, port 8080
"192.0.2.10:18080:8080", # an explicit address, host 18080 → guest 8080
"*:3000", # every interface (0.0.0.0)
]
An interface name is resolved to its first IPv4 address on every run, so a re-addressed interface is picked up without editing the config. Note what "picked up" means: the resolved address is part of the recorded port list, so a changed DHCP lease recreates the container on the next zoryn devenv — image layers are reused, but anything installed live with zoryn devenv install is gone, as with any recreate. An interface with no IPv4 address at the moment (VPN down, cable out) does not lock you out of an environment that already exists: zoryn warns and enters it with the ports it already publishes — provided those are themselves still startable. If the environment's recorded ports name the vanished address, no start could bind it (pasta would fail exactly there), so zoryn reports the dead bind instead of warning and then failing. Creating an environment always needs the address and fails with devenv.ports: interface eth0 has no IPv4 address to publish on; an address the host does not carry fails the pre-create probe with devenv.ports: address 192.0.2.10 is not configured on this host.
Binding beyond the loopback exposes the container's service to everyone who can reach that address — the container has no firewall of its own, so the entry is the whole access decision. IPv4 only. The same port may appear on several different specific addresses, but not on overlapping binds: "8080" together with "*:8080", or two entries resolving to one address with different guest ports, are refused before anything is built — both binds would fight over one socket. Two spellings of the same mapping ("8080" and "8080:8080", or an interface next to its own address) are simply published once.
Per-project ports¶
ports concatenates across config layers (like mounts, unlike the resolver settings): the machine list in ~/.zoryn carries what every environment publishes, and ~/.config/zoryn/projects.d/<project>.toml adds this project's own ports on top.
# ~/.zoryn — every environment
[devenv]
ports = ["9229"]
# ~/.config/zoryn/projects.d/mypkg.toml — this package as well
[devenv]
ports = ["eth0:8080"]
mypkg publishes both 127.0.0.1:9229 and eth0:8080. An entry written in both layers is published once. There is deliberately no per-project off-switch — a project list adds, it cannot subtract, so an empty project list does not switch the machine's ports off. Keep the global list to ports genuinely wanted everywhere: a host port is a single resource, so with a global entry two different projects cannot have running environments at the same time — only environments of the same project are arbitrated automatically; another project's holder is reported as already in use.
Notes:
- A host port may appear only once per bind address.
["8080:80", "8080:81"]is refused up front — pasta binds the port for the first mapping and fails the second, so the create would die on a list that looks valid entry by entry. The same guest port behind two host ports is fine. - Changing anything in the cache key (packages, mounts, features, image, branch…) creates a new container while the previous one keeps running — and keeps the port. zoryn stops that other environment of the same project so this one can bind, printing which container it stopped. It is stopped, not removed: whatever you installed in it survives, and entering it starts it again. Switch back later and the arbitration simply runs the other way — the environment you are entering gets the port, because a host port is a single resource and one of the two has to yield. It applies however you reach a container:
zoryn devenv,zoryn devenv enter <name>,installandfeature addall arbitrate before starting one. - Stopping a holder ends whatever is running inside it, so it happens as late as possible and only when it will actually help. Every port an outsider could hold is probed first, so if one port is held by a sibling environment and another by an unrelated process, nothing is stopped and the error names the port. On the create path the holder is stopped only after the image build succeeded, immediately before the container is created — a rejected port list or a failed build never costs you a running session. Stopping is all-or-nothing: if one holder refuses to stop, the port will not be free anyway, so any already stopped are restarted and the error names the container that would not stop.
- A host port is per bind address:
["8080", "eth0:8080"]publishes the same port on the loopback and on the LAN, and the two are arbitrated separately. The wildcard overlaps every address, so changing an entry's bind (say"8080"→"*:8080") correctly treats the running container's old bind as the one being replaced, not as a foreign holder. - A connection made from the host reaches the container with the tap/gateway address as its source, not the host's real one. LAN clients arriving through a wider bind keep their real source addresses — pasta does not NAT inbound traffic from other machines — so in-container access logs and ACLs do see the real peers.
- Ignored when
outbound_interfaceis unset — with--network hostthe container already shares the host's ports, so publishing would be redundant. - The list is out of the cache key: changing it recreates the container in place (tracked via the
zoryn.portslabel), liketimezone.
Machine config only, podman backend only.
SSH agent forwarding (forward_ssh_agent / --ssh-agent)¶
Enable forwarding of the host's SSH agent into the container so tools inside (git over SSH, ssh to remote builders) use your agent keys — including keys forwarded into your login by ssh -A. Turn it on with the --ssh-agent (-A) flag, or persistently via [devenv].forward_ssh_agent = true; the flag wins over the config.
When enabled, zoryn bind-mounts the host $SSH_AUTH_SOCK to a fixed in-container path (/run/ssh-agent.sock) and sets SSH_AUTH_SOCK to it in the container, so podman exec (the enter command) inherits it. $SSH_AUTH_SOCK must be set on the host and point at an existing socket, otherwise forwarding is skipped with a warning.
The agent socket is not part of the cache key — its path changes every login (e.g. ssh -A creates a fresh /tmp/ssh-XXXX/agent.<pid> per session). Instead the socket path is recorded on the container as the zoryn.ssh_auth_sock label; on the next zoryn devenv, if it differs from the current $SSH_AUTH_SOCK (a new login, or toggling forwarding), the container is recreated so the live socket is bound. The image build is a layer-cache hit, and the last resolved BuildRequires set is baked into the image as a warm-up layer, so the recreated container's re-resolution installs only what changed since — usually nothing. Within a single login the socket is stable, so repeated entries are instant.
Machine config only (~/.zoryn, projects.d/), podman backend only.
Apt configuration (podman)¶
The podman backend can generate its own sources.list, replacing the base image's apt sources, and bind-mount any local file: repositories it references (read-only) so apt can read them at both image-build and install time.
Specify the apt config one of four ways (precedence: --apt-conf > project config > global config; within one layer set only one key):
--apt-conf <path>— asources.listfile or a builder-style apt-config dir--apt-conf builder:NAME— reuse builderNAME's apt configapt_builder = "NAME"(config) — reuse a builder's apt configapt_config = "<path>"(config) — a sources.list file, apt.conf file, or apt-config dir (also used by the bwrap backend, normalised to an apt.conf forhsh --apt-config)apt_sources = ["rpm file:///… x86_64 classic", …](config) — inline lines
Local file: repositories are mounted read-only at the same path inside the container; a referenced path that does not exist on the host is an error.
If the configured source resolves to a file with no repository entries (blank or comments only — e.g. an empty /etc/apt/sources.list whose real entries live in sources.list.d/), zoryn prints a warning naming the file and keeps the container's default apt sources instead of failing.
A config-layer source whose repositories name a different ALT branch than the effective devenv branch is also dropped with a warning, and the base image's own sources are kept — so a global apt_config pointing at, say, a sisyphus mirror no longer breaks a -B p11 environment (the p11 image already carries correct p11 sources). An explicit --apt-conf value is applied verbatim, mismatched or not. Passing an empty value — --apt-conf '' — is a one-shot reset: any configured apt source is ignored and the base image's own sources are kept.
Machine config only (~/.zoryn, projects.d/); podman backend only for apt_builder and apt_sources.
Builder output repo (--with-builder-repo BUILDER)¶
--with-builder-repo BUILDER adds an additive apt-rpm rpm-dir source pointing at the builder's hasher output repository, so packages that were just built locally (in the hasher chroot) can be installed inside the dev environment alongside the packages from the normal repositories.
- Local builders: the builder's hasher output directory (
<hasher_dir>/repo) is mounted read-only into the container at the same host path; no copy is made. - Remote builders: the output repo is rsynced from the builder's host into
~/.cache/zoryn/devenv-repos/<builder>/repo/before the environment is prepared; the local cache is then used. - The builder's architecture must match the devenv architecture; if they differ, zoryn exits with an error.
- podman backend: a
sources.list.ddrop-in (zoryn-extra.list) is written into the container, leaving the configured base repositories intact. - bwrap backend: the repo line is merged with the existing apt sources (from
apt_config, or/etc/apt/sources.listwhen unset) and a generated apt.conf referencing the merged sources.list is passed tohsh --apt-config; the base repositories stay intact. - The repo line and repo directory are folded into the cache key, so changing or removing the flag rebuilds the environment.
- Mutually exclusive with
--with-task-repo.
Gyle task repo (--with-task-repo TASK_ID)¶
--with-task-repo TASK_ID downloads the RPMs produced by a gyle build task into a local cache and exposes them as an additive apt-rpm rpm-dir source inside the dev environment, so packages from an ongoing or completed gyle task can be installed alongside packages from the normal repositories.
- The task's RPMs are downloaded via HTTP into
~/.cache/zoryn/devenv-task-repos/<TASK_ID>/before the environment is prepared; the cache is reused on subsequent runs unless--no-cacheis given. - The downloaded RPM cache under
~/.cache/zoryn/devenv-task-repos/<TASK_ID>/is not removed byzoryn devenv clean; delete it manually when no longer needed. - The downloaded RPMs must include packages for the devenv architecture; if no packages match that arch, zoryn exits with an error.
- podman backend only — errors immediately on
--backend bwrapor any other non-podman backend. - Mutually exclusive with
--with-builder-repo. - The repo line and cache directory are folded into the cache key, so changing or removing the flag rebuilds the environment.
hasher (reserved)¶
The hasher backend name is reserved for future use. Passing --backend hasher currently exits with an error (project-mount scheme TBD).
Configuration¶
The [devenv] section controls which extra packages are installed and which backend is used.
Config locations¶
There are three places to put [devenv] configuration, each with a different scope:
| File | Scope | Accepted keys |
|---|---|---|
~/.zoryn | Global — all packages on this machine | backend, packages, image, branch, build, build_user, mounts, pids_limit, prompt, timezone, outbound_interface, dns, dns_search, dns_option, ports, env, forward_ssh_agent, apt_config, apt_builder, apt_sources, default_profile; [devenv.lan], [devenv.features.<id>] tables |
~/.config/zoryn/projects.d/<project>.toml | Per-project, machine-local | same as ~/.zoryn |
.gear/devenv | Per-package, committed, shared with the team | packages only |
.gear/devenv intentionally does not accept backend, image, branch, build, build_user, mounts, pids_limit, prompt, outbound_interface, dns, dns_search, dns_option, env, forward_ssh_agent, profiles, or [devenv.features.<id>] tables — those are machine and developer choices, not something to encode in the repository.
Profiles¶
A profile is a named, fully independent [devenv] configuration, declared as [devenv.profiles.<name>] (in ~/.zoryn or projects.d/<project>.toml). Select one with --profile <name>/-p <name>; if omitted, [devenv].default_profile is used, and if that is unset only the base section applies. Use profiles to keep, say, a sisyphus and a p11 environment side by side and switch between them.
When a profile is active, every [devenv] key (backend, packages, image, branch, build, build_user, mounts, pids_limit, prompt, timezone, outbound_interface, dns, dns_search, dns_option, ports, env, forward_ssh_agent, apt_config, apt_builder, apt_sources, [devenv.lan], and [devenv.features.*]) is read only from [devenv.profiles.<name>]. The base [devenv] section is not consulted — its values are neither inherited nor merged into the profile. The base section applies only when no profile is active. A key absent from the active profile is simply unset, not borrowed from the base.
The only carve-out: a package's own BuildRequires (resolved from its spec) and packages committed in .gear/devenv are always installed regardless of the profile — they describe the package itself, not the developer's environment.
default_profile itself is always read from the base [devenv] (selecting a profile from within a profile would be circular).
The branch key sets the target ALT branch (default sisyphus); when image is unset it derives the base image registry.altlinux.org/<branch>/alt:latest. zoryn devenv --branch <name>/-B is a one-shot override that wins over the configured branch without editing config (handy to try another branch once). Each resolved profile (or --branch) produces its own cached environment (branch, packages and image are part of the cache key), so two coexist and both show up in zoryn devenv list.
How branch affects the env depends on backend: podman pulls the matching base image, so its apt repositories really are that branch's; bwrap uses hasher, which reads the host apt config, so branch only names/separates the cached chroot — point hasher at the branch's repositories with apt_config (below).
[devenv]
packages = ["gdb"] # used ONLY when no --profile is active
default_profile = "sisyphus" # used when --profile is omitted
[devenv.profiles.sisyphus]
branch = "sisyphus"
packages = ["gdb"] # independent: list it here too if you want it
[devenv.profiles.p11]
branch = "p11"
packages = ["vim"] # → vim only (base "gdb" is NOT inherited)
Features¶
A feature is a named, reusable bundle that contributes build packages, install steps, and bind-mounts to the development environment. It is defined as a feature.toml file.
Selection is a map, not a list. A feature is selected by adding its [devenv.features.<id>] table to your config. An empty table selects the feature with no options; keys inside the table are the feature's typed options.
[devenv.features.claude] # selected, no options
[devenv.features.opam] # selected, with an option (local opam feature)
version = "5.3.0"
Since zoryn 0.49.0 (the switch to the otoml parser) the same selection can be written as a TOML inline table — one line instead of a header per feature:
The two spellings are equivalent. The only caveat is TOML's own layout rule: an inline features = ... key must appear inside the [devenv] block before any [devenv.*] section header, because a bare key = value line belongs to the most recent section header above it.
Profile-aware (layer-select). Like every other [devenv] key, feature selection is per-layer — a profile is fully independent of the base:
- No profile active — only
[devenv.features.<id>]tables (the base layer) apply. - Profile active — only
[devenv.profiles.<p>.features.<id>]tables (the profile layer) apply; base[devenv.features.*]are not inherited.
[devenv.features.claude] # used only when no --profile
[devenv.profiles.p11.features.gdb] # used only with --profile p11 (claude NOT included)
Options for a feature come from its table in the active layer. Across files (global ~/.zoryn + project-local), ids are unioned and options are project-over-global — within the active layer.
A feature contributes:
packages— merged into the apt install alongside the spec'sBuildRequires:and your ownpackages.run— array of shell commands run as root (equivalent tobuild) during image build.run_user— array of shell commands run as the host user (equivalent tobuild_user) after theUSERswitch.mounts— extra bind-mounts added to the container;{devenv}/{project}/{branch}/{profile}placeholders are expanded as in the config key (see mounts).env— array ofNAME=valueentries set in the environment's shell (podman--envat container creation, bwrap--setenvat enter); values may use the{devenv}/{project}/{branch}/{profile}placeholders, expanded as inmounts. An entry whose name is not a valid identifier, or that carries no=, is rejected when the feature is parsed. Entries from[devenv].envin your config are applied after the features', so your config wins for a name both set. Changing the effective set recreates the podman container (image layers are reused).devices— host device paths exposed to the container via--device(e.g./dev/net/tun). A device missing on the host is skipped with a warning instead of failing container creation. Likemounts, devices are fixed at container-create time, so changing them recreates the container.capabilities— capability names added to the container via--cap-add(e.g.SYS_ADMIN; theCAP_prefix is optional, podman accepts both spellings). Anything outside[A-Z][A-Z0-9_]*is rejected when the feature is parsed. Likemounts, capabilities are fixed at container-create time, so changing them recreates the container.security_opts—--security-optentries applied to the container (e.g.unmask=/proc/*). Each entry must bekey=valuewith a key podman knows (seccomp,unmask,mask,label,apparmor,no-new-privileges,proc-opts) and a value free of whitespace and shell metacharacters other than*; anything else is rejected when the feature is parsed. Also fixed at container-create time.
Feature steps run before your own build/build_user commands (features-first). In the podman image this is a layer-cache boundary, not just an ordering: every feature gets its own group of layers — an apt layer with the feature's packages, then its run/run_user steps — before anything project-specific (the project's own packages, your build steps, the prompt). A feature's layers depend only on the features before it, so projects sharing a feature prefix share those layers byte-for-byte, and adding or removing a feature does not disturb the layers of the features listed before it — a feature's installer (claude's curl … | bash, say) runs once per machine for a given base image, branch and apt configuration, not once per project and not again when the feature set grows behind it. The flip side of the ordering: a feature step cannot rely on the project's packages, on your build steps, or on the packages of features after it — those run later — so a custom feature must declare everything it needs in its own packages (the bundled features already do). A feature's typed options are passed to its commands as environment variables: each option name is upper-cased and exported into the command's environment (e.g. channel → CHANNEL). The env prefix is applied to each run/run_user entry individually.
No ${VAR} substitution in the command text
zoryn does not perform ${VAR} substitution on the command string. Writing ${CHANNEL} literally in a run/run_user entry will not be expanded to the option value — the env prefix CHANNEL='latest' cmd does not make ${CHANNEL} visible to cmd's own argument list in POSIX sh (the parameter expansion happens before the assignment takes effect, so it is empty). The value reaches a program only through the environment: the command (or an installer it pipes into) must read $CHANNEL itself via getenv.
Inside a mounts or env entry, by contrast, an option can be referenced as ${NAME} (the upper-cased option name) and is substituted textually with the option value before the entry becomes a -v / --env flag (e.g. ~/.x:~/.x:${CONFIG_ACCESS} with config_access = "ro" yields ~/.x:~/.x:ro). This is in addition to options being exported as env vars to run/run_user; it lets an option parameterise a mount or an environment entry (a -v or --env spec is not a shell command, so the env-prefix mechanism does not apply there). In an env entry only the value side can be parameterised — the entry must already be a valid NAME=value when the feature is parsed, and a substitution result carrying a control character, a trailing space, or a brace other than the {devenv}/{project}/{branch}/{profile} placeholders is rejected with an error naming the feature.
Resolution by id: a local definition overrides the built-in with the same id. It lives at ~/.config/zoryn/devenv/features/<id>.toml, or as <id>/feature.toml in a directory (handy for auxiliary files); the directory form wins when both exist.
Bundled features:
claude— installs the Claude Code CLI into your~/.local/bin; bind-mounts~/.claudeand~/.claude.jsonso your login and settings persist across container runs. Optioninstaller:native(default) runs the officialinstall.sh, which downloads a standalone binary;npminstalls@anthropic-ai/claude-codefrom the npm registry instead (pulling innpm/node) — use it where the native download is slow or blocked, since it honours your npm registry mirror.GLM-claude-code— identical toclaude(sameinstalleroption), but bind-mounts a separate profile: host~/.config/claude-glm→ container~/.config/claudeand host~/.claude-glm.json→ container~/.claude.json, keeping a GLM login/config apart from the defaultclaudeprofile.codex— installs the OpenAI Codex CLI via its official installer; bind-mounts~/.codexso its login persists.opencode— installs the opencode CLI; bind-mounts~/.local/share/opencode,~/.config/opencodeand~/.agentsso its login, config and agent definitions persist.pi— installs the pi.dev coding agent into~/.local/bin; bind-mounts the host's~/.pi/agentand shared~/.agentsdirectories read-write so provider logins, settings, sessions, extensions, skills and packages are available in the environment and changes made inside persist on the host. Project-local.piand.agentsdirectories are already available through the project mount.kimi— installs the Kimi Code CLI (MoonshotAI) into~/.local/kimi(the binary lives outside the mounted state dir) and bind-mounts~/.kimi-codeso its login,config.toml, MCP servers and sessions persist.vim— installsvim-consoleandvim-plugin-spec_alt-ftplugin, and bind-mounts the host's vim config:~/.vimrcread-only, and~/.vimread-only by default. Set[devenv.features.vim] config_access = "rw"to mount~/.vimread-write so vim's runtime writes (swap, undo, netrwhist, spell, plugin data) don't fail.~/.viminfolives in$HOME, not~/.vim, so history persists regardless.hasher— build packages withhasherinside the environment: installshasherandhasher-kayfabe(0.2.1 or newer — the image build stops with a clear message on anything older), creates the satellite users for the dev user, and puts wrappers in front ofhsh,hsh-runand friends. Those starthasher-privdon first use, so an environment that never builds anything never runs a daemon.hasher-privinsists on things a rootless container does not offer — it compares namespaces with its caller, treats a read-only cgroupfs as fatal, and creates the chroot's device nodes withmknod()— andhasher-kayfabeanswers each with what the process would have seen on a host. The feature also writesallowed_mountpoints=/proc,/dev/ptsandallowed_devices=/dev/kvminto/etc/hasher-priv/system: hasher-priv refuses any mountpoint not listed there, and zoryn's own build commands pass--mountpoints=/proc,/dev/pts. The environment runs withCAP_SYS_ADMIN(hasher-priv mounts), which weakens isolation — select it deliberately; nothing else is relaxed, and/procstays masked as podman left it. Root for starting the daemon comes from a setuid helper that can do that one thing and only for thehashmangrouphasher-useraddputs the dev user in, so thesudofeature is not required — though membership of that group is a path to in-container root, likesudowould be. Two limits worth knowing: the container's cgroupfs stays read-only, so every build prints one warning and runs without hasher's per-job cgroup limits; and exposing a host/dev/kvmto the container is a separate decision ([devenv].devices, or a spec that asks for it). The host's~/.hasher/configis bind-mounted read-only, so the environment builds with your own hasher settings — above all the packager: hasher takes it from that file (orhsh --packager) and never from~/.rpmmacros, and without one it bakesAutomated package hasher <hasher@localhost>into the chroot andsisyphus_checkrejects every build with "wrong PACKAGER". The file is sourced by the shell, so a value with spaces needs quoting:packager='Your Name <you@altlinux.org>'. It is read when the chroot is created, so a changed packager takes effect on the nexthsh --initroot. A read-only mount whose host file does not exist is skipped with a warning naming it, rather than letting podman create a directory in its place — so a host without~/.hasher/configor~/.rpmmacrosstill gets a working environment, one that says what it did not carry in. The host's~/.rpmmacroscomes in read-only too, for the builds that run in the environment directly (gear-rpm, rpmbuild): hasher ignores that file, but everything else takes%packagerfrom it. Options:[devenv.features.hasher] tmpfs— the workdir mount,tmpfs:~/hasher:size=8g,mode=1777by default (a chroot is thousands of small files, and on the container's overlay every one of them is a write to disk); set it to""to keep the workdir on the overlay, or change the size there.hasher_config— that read-only mount,""to keep the environment's hasher configuration to itself.sudo— gives the dev user passwordless root inside the container. Without this feature an environment has no root access at all: the feature writes the/etc/sudoers.d/<user>rule and deals with ALT shipping/usr/bin/sudoas mode 4710root:wheel(control sudo wheelonly), which stops a user outside thewheelgroup from executing the binary in the first place — it switches the facility topublic(mode 4711root:root), leavingsudoersin charge of what may actually be run. Select it whenever something inside the environment installs packages or otherwise needs root.ssh— installsopenssh-clientsand bind-mounts the host's~/.sshread-only, so keys,configandknown_hostsare available inside (e.g. for git-over-ssh and remote builders). Being read-only,known_hostsupdates from inside the container do not persist.chromium— installs Chromium with DejaVu fonts (fontconfig included) for headless browsing and screenshots, typically driven by AI agents. The setuidchrome-sandboxhelper does not work under rootless podman, so the feature appends--disable-setuid-sandboxto/etc/chromium/default; Chromium's namespace sandbox still works, so page isolation is kept. Agent-downloaded browsers (puppeteer/playwright) also start working, since the package brings in all the shared libraries they need.podman— nested rootless podman inside the container: run, build and pull images, or even start a nestedzoryn devenv(pair it with thezorynfeature). Installs podman with pasta networking and grants the dev user sub-id ranges that fit the container's own user namespace. Image storage lives in~/.local/share/zoryn-devenv/containerson the host — bind-mounted so the native overlay driver works (storage on the container's own overlay filesystem would silently fall back to the slow vfs driver); it is shared by all your devenv containers, so pulled images are cached across them. The host's/dev/net/tunis exposed to the container, because pasta — the inner podman's network backend — opens it to create its tap device. Be aware that environments selecting this feature run withCAP_SYS_ADMINand an unmasked/proc— the inner podman needs both to mount its own procfs and set its containers' hostnames — which weakens the container's isolation, though in rootless mode the capability is confined to the container's own user namespace and grants no host privilege, and seccomp filtering stays fully enabled. Needs a host kernel 5.13 or newer. The sub-id helpersnewuidmap/newgidmapare grantedcap_setuid/cap_setgidinstead of ALT's default capability set (which podman's container does not grant), and/etc/login.defsis made world-readable inside the container becausenewuidmapreads it at startup.zoryn— installs thezorynpackage from the container branch's repo and bind-mounts the host's zoryn config (~/.zorynand~/.config/zoryn, which holdsbuilders.d/) read-only, sozorynruns inside the environment against your host builders/settings without being able to modify them. The package ships its Agent Skill under/usr/share/zoryn/skills/, so no skill mount is needed. The package must exist in the target branch's repository.
This is machine config only — [devenv.features.<id>] tables are accepted in ~/.zoryn and ~/.config/zoryn/projects.d/<project>.toml, but not in the committed .gear/devenv. Podman backend only; a feature not declaring the current backend in its backends list is skipped with a warning.
Example local feature definition:
# ~/.config/zoryn/devenv/features/claude/feature.toml
id = "claude"
version = "1"
backends = ["podman"]
packages = ["curl"]
run_user = ["curl -fsSL https://claude.ai/install.sh | bash"]
mounts = ["~/.claude", "~/.claude.json"]
[options.channel]
type = "enum"
values = ["stable", "latest"]
default = "stable"
The channel option is exported as CHANNEL into the installer's environment, so the install.sh script picks it up if it reads $CHANNEL (a getenv); the value is not interpolated into the command string itself.
One-shot --feature¶
-F ID, --feature ID — One-shot: additionally enable devenv feature ID for this run, on top of the features from [devenv.features] or the active profile (deduplicated; nothing is removed). Repeatable. Unknown ID is an error. Options for a feature also present in config are reused; a feature enabled only via --feature uses its built-in defaults.
# Pull in the 'claude' feature for one shell without touching config
zoryn devenv --feature claude
# Several at once
zoryn devenv -F claude -F vim
Dependency set¶
The environment is populated from two sources:
1. The spec's BuildRequires:, resolved inside the environment in two passes (modelled on hasher's hsh-rebuild), so rpm macros and %if conditionals are expanded by a real rpmbuild for the target arch — not parsed textually on the host:
- Pass 1 installs
BuildRequires(pre):(the packages that define the macros, e.g.rpm-build-python3), read from the raw spec with macro tokens dropped. - Pass 2 runs
rpmbuild -bEon the spec (now that the macro packages are present), then installs the full expandedBuildRequiresset. This also picks up dynamic build dependencies injected by build-body macros (%pyproject_build,%set_gcc_version, …): they accumulate in%_buildrequires_buildrather than the preamble, so zoryn expands a copy of the spec with%{?_buildrequires_build}appended after the body and installs what that dump reveals (e.g.python3-module-pyproject-installerpulled in by%pyproject_*). During expansion%_sourcedirpoints at the spec's own directory (.gear/), so macros that read auxiliary source files from there — such as rpm-build-pyproject's%pyproject_builddeps_*, which evaluatepyproject_deps.json— resolve their generated dependencies too. If acopy:rule in.gear/rulestakes a file from any other directory, that file is invisible to such macros, and resolution prints a warning naming it.
rpm-build is always present in the environment so the spec can be expanded (bash and git come with it). sudo does not — root access arrives only with the sudo feature. Capability requires (pkgconfig(...), perl(...)) are resolved by apt (podman) / hasher (bwrap). Version constraints are dropped — a dev environment installs by name. Packages whose scriptlets insist on a hasher environment (e.g. rpm-build-vm-run, which checks for /.host, /.in, /.out) find those marker directories already created in the podman container — always, since such a package can arrive as a dependency of a requested one (rpm-build-vm pulls rpm-build-vm-run) (the bwrap backend is already a hasher chroot). A kernel installed as a build dependency does not generate an initrd or /boot symlinks: the image bakes /etc/sysconfig/installkernel (NOFLAVOUR=y, NODEFAULT=y, INITRD_AUTOUPDATE=none), so installkernel exits early — a dev container needs the kernel only to build against it, never to boot. When the spec needs KVM for a VM-based build (a /dev/kvm BuildRequires, or a KVM-pulling package like rpm-build-vm-run) and the host has /dev/kvm, it is passed into the podman container via --device /dev/kvm.
2. Configured extra packages — the union (deduplicated) of packages from [devenv] in ~/.zoryn (your personal toolkit, e.g. gdb, vim, strace), .gear/devenv (team-shared extras), and ~/.config/zoryn/projects.d/<project>.toml (machine-local per-project extras).
Editing the spec recreates the environment (its digest is part of the cache key).
After each successful resolution zoryn memoises the full resolved set per project+branch+arch (under ~/.cache/zoryn/devenv/buildreqs/) and bakes it into the derived image as a best-effort final layer. A container recreated for a reason outside the cache key — an env/ports/dns/timezone change, a new SSH agent socket, --rebuild — then starts with its build dependencies already present, and the authoritative in-container resolution installs only the delta. The layer is || true: a stale memo (a renamed package after a branch change) can never fail the build, it just falls back to installing inside the container.
For template specs that use gear specsubst placeholders (e.g. kernel-image's Name: kernel-image-@kflavour@), the @var@ values are expanded before resolution — exactly as zoryn build does for a short-circuit section build — taking each value from git config gear.specsubst.<var> or the [specsubst] section of .gear/version-up. If a value is unset, zoryn devenv stops and tells you to set it.
Skipping resolution (--no-build-deps)¶
Some specs cannot be resolved on the current machine at all: an ExclusiveArch:/ExcludeArch: that shuts out the host architecture makes rpmbuild -bE refuse the spec (error: Architecture is not included: x86_64) — typical for firmware/bootloader packages such as u-boot-*, which you edit locally and build on a remote builder of the right architecture. For that workflow pass --no-build-deps: the spec is skipped entirely and the environment holds only the configured [devenv] packages. When resolution fails on this rpmbuild error, zoryn devenv prints a hint recommending the flag.
The flag is one-shot: a later run without it resolves (and fails) again, so pass it on every zoryn devenv for such a package.
Backend resolution order¶
--backend flag → ~/.config/zoryn/projects.d/<project>.toml → ~/.zoryn → an already-cached environment for this package → default (podman if installed, else bwrap).
When you express no preference, zoryn devenv re-enters an existing cached environment automatically: if exactly one of podman/bwrap has a prepared env for the current dependency set, that backend is used. So after building a podman environment once, a bare zoryn devenv enters it without rebuilding. When nothing is cached either, the default backend is podman when podman is installed, falling back to bwrap otherwise.
Image resolution order (podman only)¶
--image flag → ~/.config/zoryn/projects.d/<project>.toml → ~/.zoryn → registry.altlinux.org/<branch>/alt:latest.
Set image (or pass --image) when the default is not pullable on your host (e.g. you maintain a local mirror or use a registry with a different tag scheme).
~/.zoryn example¶
[devenv]
backend = "bwrap"
packages = ["gdb", "vim", "strace", "ltrace"]
# image = "registry.example.com/alt/sisyphus" # override when the default isn't reachable
# build = ["go install golang.org/x/tools/cmd/goimports@latest"] # podman: extra image-build RUN steps
.gear/devenv example¶
~/.config/zoryn/projects.d/<project>.toml example¶
zoryn devenv install¶
zoryn devenv install gdb strace # add gdb and strace to the prepared env
zoryn devenv install --save heaptrack # add heaptrack and remember it in .gear/devenv
zoryn devenv install --backend podman ltrace
zoryn devenv install --container blender-abc123 gdb # into a specific container
zoryn devenv install --container . gdb # into the current directory's container
Installs extra packages into the current package's prepared environment (the cached base), so the next zoryn devenv shell has them without a full rebuild. This host-side command exists because the bwrap dev shell is unprivileged over a read-only chroot — you cannot apt-get from inside it.
If no environment has been prepared yet, install prepares it first (the full resolved dependency set, exactly as zoryn devenv would), then installs the extra packages on top. For the podman backend it installs them live into the running container as in-container root (podman exec --user root … apt-get install), with no image rebuild; for bwrap it installs into the cached chroot.
--container¶
By default the target environment is computed from the current directory's resolved config — the same cache key plain zoryn devenv would use. --container CONTAINER (podman only) instead installs straight into that existing container (name/ID as shown by zoryn devenv list, or . for the current directory's container), resolving no project config — it works from any directory, like zoryn devenv enter. Use it to target a container whose cache key differs from the plain config, e.g. one created with a one-shot --mount-bind. Environment-selection flags (--backend, --image, --apt-conf, …) are ignored with --container.
--save still writes the current directory's .gear/devenv, so combining it with --container is allowed only when the container belongs to the current directory (its zoryn.project label matches); otherwise the command refuses before installing anything.
--save¶
Also append the named packages to [devenv].packages in .gear/devenv as a sorted, deduplicated union with the existing entries. This makes them survive a cache rebuild and shares them with the team (the file is committed). Other keys in the file are preserved. If there is no .gear/ directory, the save is skipped with a warning.
The hasher backend is not supported here (same project-mount scheme TBD error as zoryn devenv).
zoryn devenv feature add¶
Adds one or more devenv features to the current project's running podman container in place — without recreating it. This is the counterpart to zoryn devenv install for features: the feature's packages are installed and its run/run_user steps executed live, via podman exec, in the container you are already using.
Unlike --feature/-F (which folds the feature into the environment's identity and so recreates the container to build it in), feature add acts on the running container directly. The effect is therefore ephemeral — like packages added with zoryn devenv install, it lives only in the container and is dropped on the next full rebuild (--rebuild, or zoryn devenv clean).
Constraints:
- Podman only. If there is no prepared environment yet, run
zoryn devenvfirst. - A feature that declares a bind-mount, an environment entry, a device, a capability, or a security option cannot be attached to an already-running container and is refused — use
zoryn devenv -F IDinstead, which recreates the environment with them in place. - An unknown feature id is an error.
zoryn devenv clean¶
zoryn devenv clean # remove the cached env for the current package
zoryn devenv clean --backend podman # target the podman-backend cache specifically
zoryn devenv clean --all # podman: remove ALL of this project's envs (any cache key)
zoryn devenv clean --project /path/to/proj # podman: remove a named project's envs, from anywhere
Removes the cached base for the current package's dev environment so the next zoryn devenv rebuilds it from scratch.
- bwrap: runs
hsh-rmchrooton the chroot (required because the chroot subdirectory is owned by hasher-priv subuids and cannot be removed with plainrm), then removes the entire cache directory. - podman: removes the persistent container with
podman rm -fand the built image (zdevenv-<pkg>-<branch>:<key>) withpodman rmi -f.
The environment targeted is the one matching the current resolved backend, dependency set, architecture, branch, and image. Switching --backend, branch, or image (via config) targets a different cached environment.
--all (podman)¶
A single clean only removes the one environment matching the current config. Changing config that feeds the cache key leaves the previous environment running, so stale environments accumulate over time: prompt, extra packages or the image produce a new image and container, while mounts and outbound_interface produce a new container from the same image.
zoryn devenv clean --all removes every dev environment of the current project at once, regardless of cache key. Containers are tagged at creation with a zoryn.project=<project-dir> label; --all removes all containers carrying the current project's label and then rmis their backing images (non-force, so an image still referenced by another project's container survives). Other projects' environments are never touched. Containers created before this labelling was introduced lack the label and must be removed manually (podman rm -f <name>).
--project PATH (podman)¶
zoryn devenv clean --project PATH removes every dev environment of the project at PATH — its directory path (the zoryn.project value shown by zoryn devenv list) or its basename — from anywhere, without being in that directory. Useful to reclaim disk from another project's environments straight off the list output. It needs no prepared environment or resolvable config in the current directory (so it works even when the current package's devenv config can't be resolved). Backing images are rmi'd non-force. Overrides --all and the current-directory target.
--all applies to the podman backend only; for bwrap it removes just the current chroot (bwrap chroots are shared by dependency set, not per-project).
If no cached environment exists, clean is a no-op and prints a notice.
The hasher backend is not supported (same project-mount scheme TBD error as zoryn devenv).
zoryn devenv list¶
Lists every dev environment zoryn created, across all projects and cache keys — useful for finding stale ones that accumulated as configs or dependencies changed (each distinct config gets its own environment).
- podman: every container carrying the
zoryn.devenvlabel, in columns under a header —PROJECT,CONTAINER,STATUS,FEATURES,PROFILE. The last two are what tell two environments of the same project apart: the cache key in the container name does not say what went into it. A container built with no features, outside a profile, or before those labels existed shows-;zoryn devenv inspect <name>prints the full recorded configuration. --verboseadds, for every project with more than one environment, the labels whose value is not the same in all of them — in practice the computedpackages(a changedBuildRequires:) or thespec_hash, which is what two environments with identical features usually part over. If nothing recorded differs, the cache keys parted over something not kept as a label, such as a one-shot--mount-bind. Long values are shown from the point where they start to differ (…hasher-fake:/…), since a mount or package list that parts in the middle is otherwise all shared prefix.- bwrap: prepared chroot cache directories under
~/.cache/zoryn/devenv/(content-addressed by dependency set, so no project name — just the path).
To remove environments: zoryn devenv clean --all (from a project, removes that project's podman environments), zoryn devenv clean (the current config's), or podman rm -f <name> for a specific container.
zoryn devenv packages¶
zoryn devenv packages # one package per line
zoryn devenv packages --verbose # grouped by source
zoryn devenv packages --profile p11 # under that profile
zoryn devenv packages --no-build-deps # config + features only
zoryn devenv packages -F vim # plus that feature's packages
Prints the packages a zoryn devenv run would install into the environment, without preparing one. The default output is the sorted unique union, one name per line — suitable for piping.
The list is assembled the same way the runtime does, from:
- The spec's static
BuildRequires:/BuildPreReq:(including(pre)), read from the raw spec. Macro tokens are dropped; there is no in-environmentrpmbuild -bEexpansion, so dynamic body-injected deps (%pyproject_buildand friends) are not seen. - Configured
packagesfrom~/.zoryn,.gear/devenv, andprojects.d/<project>.toml, resolved under the active profile (explicit--profile, else[devenv].default_profile, else the base section). - Packages contributed by the selected features (config +
--feature/-F), filtered to those that support the resolved backend (flag, then config, then podman). A feature that does not support the backend is skipped with a warning, as onzoryn devenv.
--verbose prints each source as its own group (buildrequires, ~/.zoryn, .gear/devenv, projects.d/<project>.toml, features/<id>) instead of the flat union. --no-build-deps skips the spec, matching that flag on zoryn devenv. --profile, --backend and --feature have the same meaning as on the main command.
The fully expanded BuildRequires — after the two-pass resolution inside a prepared environment — are recorded on the container and shown by zoryn devenv inspect.
zoryn devenv enter¶
zoryn devenv enter # enter the current directory's container
zoryn devenv enter <CONTAINER> # enter an existing container by name/ID
zoryn devenv enter <CONTAINER> --root # enter as root
zoryn devenv enter <CONTAINER> -u 1000 # enter as a specific user
zoryn devenv enter <CONTAINER> -- make # run a command instead of a shell
zoryn devenv enter . -- make # run a command in the default container
Enters an already-created podman dev-environment container directly by its name or ID (as shown by zoryn devenv list), starting it first if stopped. Unlike plain zoryn devenv, this resolves no project config and need not be run from any package directory — handy to jump straight into a specific container (e.g. one belonging to another project). The shell lands in the container's configured working directory (the project it was created for).
CONTAINER may be omitted: the (newest) container created for the current directory is entered, falling back to the only existing container when the directory matches none. . is an explicit placeholder for the same default — use it to combine the default container with a command, as in zoryn devenv enter . -- make.
Only containers created by zoryn devenv (carrying the zoryn.devenv label) can be entered; any other name is rejected. --root is a shortcut for --user root (and wins over an explicit --user). Pass a command after -- to run it instead of an interactive shell.
There is no --mount-bind here: podman cannot attach a bind-mount to an existing container. To add or change mounts, run plain zoryn devenv --mount-bind SPEC from the project directory — the container is recreated with the new mounts (image layers are reused, so it is cheap).
zoryn devenv remove¶
Removes a single podman dev-environment container directly by its name or ID (as shown by zoryn devenv list), together with its backing image (removed non-force, so an image still referenced by another container survives). Resolves no project config and need not be run from any package directory. Only containers created by zoryn devenv (carrying the zoryn.devenv label) can be removed; any other name is rejected.
Use this to drop one specific environment from the list output; zoryn devenv clean --project PATH removes all of a project's environments at once, and zoryn devenv clean removes the one matching the current config.
zoryn devenv inspect¶
Prints the zoryn config recorded on a podman dev-environment container at creation — its cache key, image, branch, architecture, configured packages, selected features, mounts, build/build_user steps, prompt/outbound_interface/dns/apt_config, and the spec digest. These are stored as zoryn.* container labels at creation, so each environment self-describes what produced it.
It also prints the resolved BuildRequires actually installed, recorded inside the container during preparation and split by hasher pass — build_requires(pre) (pass 1, the BuildRequires(pre) macro-defining packages) and build_requires (pass 2, the full expanded set) — so the two stages can be told apart and compared independently.
Use it to understand an opaque entry in zoryn devenv list, or to compare two environments of the same package — inspect both and diff the output to see exactly which input differs (e.g. a changed spec_hash, an added feature, or different packages/build_requires). Containers created before this labelling show only the base labels. (The label data is also in podman inspect <name> --format '{{json .Config.Labels}}'; the build-deps are in /var/lib/zoryn-devenv/ inside the container.)
The spec is not part of the podman cache key. A podman environment's identity is its config (image, branch, packages, features, mounts, build steps, …) — not the spec — so editing the spec never creates a new container. Instead, on the next zoryn devenv the existing container is reused and its build dependencies are reconciled:
- zoryn compares a digest of the spec up to
%changelog(spec_hash, recorded inside the container asbr_digest). Unchanged → enter immediately; changed → re-resolve, install the delta, and update the record. - On a change it re-resolves the spec's
BuildRequiresinside the container andapt-installs only the missing ones (idempotent — already-present packages are a no-op). - The build body is part of the digest because it drives dynamic
BuildRequires(%pyproject_buildand friends), so editing it re-resolves; only%changelog(high-churn, dependency-irrelevant) is excluded. - Adding a
BuildRequiresjust installs it — no rebuild. RemovedBuildRequiresare left installed (harmless for a dev env;zoryn devenv cleanfor a clean slate).
The bwrap backend still keys on the spec (its chroot is rebuilt on a spec change rather than reconciled).
zoryn devenv profile list¶
zoryn devenv profile list # names only
zoryn devenv profile list --verbose # each profile's resolved config
Prints the name of every [devenv.profiles.<name>] profile defined in the machine config (~/.zoryn) and the per-project local config (projects.d/<project>.toml), one per line, sorted and deduplicated. Profiles that appear only through a nested [devenv.profiles.<name>.features.<id>] header are listed too.
With --verbose, prints each profile's effective [devenv] configuration instead — the keys and features it resolves to (backend, image, branch, packages, build, features, …), merging the machine and per-project local configs the same way the runtime does. Only set keys are shown.
The plain form is what shell completion calls to offer the right values on zoryn devenv --profile <TAB>, so the completion always reflects your actual configured profiles instead of a hardcoded list.
Caching¶
Prepared environments are cached under ~/.cache/zoryn/devenv/<key>/ (bwrap), where the key is derived from the dependency set, backend, architecture, branch, and — for the podman backend — the base image. Changing image therefore targets a different environment rather than reusing a stale one. A cached environment is reused on subsequent launches without reinstalling packages.
The architecture is part of the key, but the environment is always built for the host architecture — there is no cross-architecture emulation.
For the podman backend, the cache is a built image (zdevenv-<pkg>-<branch>:<key>) plus a persistent container (zdevenv-<pkg>-<branch>-<key>) created from it — not a directory. The container is reused — restarted if stopped — on every launch; zoryn devenv clean removes both the container and the image.
The container's project bind-mount is fixed when it is first created. The cache key does not include the working directory, so if the package directory later moves (or you run zoryn devenv for it from a different absolute path), the reused container still binds the original path and your edits won't be visible inside. Run zoryn devenv clean (or --rebuild) to recreate it against the new location.
Pass --rebuild to drop the cached environment and recreate it (for podman, this rebuilds the image and recreates the container); add --no-cache to also bust podman's build-layer cache so every layer (apt dist-upgrade, deps) is re-run. On its own, --no-cache is just a build modifier — it neither forces a rebuild nor deletes anything. Rebuilding is useful after a BuildRequires: change or when the base image has gone stale since the env was prepared.
See also¶
- Configuration —
~/.zorynreference - Sandbox — bubblewrap and hasher-based isolation used by zoryn hooks