Repository: erikarvstedt/extra-container
Branch: master
Commit: b450bdb24fca
Files: 15
Total size: 72.5 KB
Directory structure:
gitextract_dnnhq23t/
├── CHANGELOG.md
├── LICENSE
├── Makefile
├── README.md
├── default.nix
├── eval-config.nix
├── examples/
│ └── flake/
│ ├── flake.nix
│ └── usage.sh
├── extra-container
├── flake.nix
├── run-tests-in-container.sh
├── test.sh
└── util/
├── generate-sudoers.rb
├── install.sh
└── update-readme.rb
================================================
FILE CONTENTS
================================================
================================================
FILE: CHANGELOG.md
================================================
# 0.14 (2025-12-19)
- Enhancements
- Support NixOS unstable
# 0.13 (2024-12-12)
- Enhancements
- Support NixOS option `containers.<name>.autoStart`.
This allows creating containers that are automatically started on system boot.
- Support NixOS 24.11, NixOS unstable
# 0.12 (2023-06-15)
- Enhancements
- Flake: Allow accessing built container configs.
Example:
```bash
nix eval ./examples/flake --apply 'sys: sys.containers.demo.config.networking.hostName'
```
- Add compatibility with NixOS 23.05, NixOS unstable
- Fixes
- Fix `extra-container destroy --all` when more than one container is installed
# 0.11 (2022-10-22)
- Enhancements
- Support building containers via Flakes (see [examples/flake](./examples/flake)).
- Support destroying containers from container definitions:\
If you have installed containers with command `extra-container create ./mycontainers.nix`,
you can now destroy these containers with the analogous command
`extra-container destroy ./mycontainers.nix`.
- Fixes
- Fix incomplete container state directory path in `help()` message
# 0.10 (2022-06-26)
- Enhancements
- Support NixOS 22.05
# 0.9 (2022-04-11)
- Enhancements
- Support NixOS unstable
- Fixes
- Fix command `destroy` for nested declarative containers
# 0.8 (2021-09-30)
- Enhancements
- Support NixOS unstable
- Fixes
- Fix flake
# 0.7 (2021-08-03)
- Enhancements
- Support NixOS 21.05 and unstable
- Add basic [Nix flake](https://nixos.wiki/wiki/Flakes) support
for installing and developing.\
`extra-container` itself still uses `nix-build` internally.
# 0.6 (2021-02-05)
- Fixes
- Add compatibility with current NixOS unstable.
- `extra.exposeLocalhost`: don't fail when iptables lock can't be obtained immediately.
- Fix `PATH` not being preserved in container shells.
# 0.5 (2020-11-01)
- Enhancements. (See the [README](README.md) for full documentation.)
- Add generic support for systemd-based Linux distros.
- Add command `shell`.
- Add extra container options:\
`extra.enableWAN`\
`extra.exposeLocalhost`\
`extra.firewallAllowHost`\
See [eval-config.nix](eval-config.nix) for descriptions.
- Add option `--ssh`.
- Add option `--expr|-E`.
- Append `pwd` to `NIX_PATH` to allow accessing the working dir in non-file configs.
- Support nixpkgs versions > 20.03.
- Automatically run as root via `sudo`.
- Fixes
- Don't copy local nixpkgs sources provided via `--nixpkgs` to the nix store.
# 0.4 (2020-09-25)
- Enhancements
- Significantly speed up container evaluation.\
Use a reduced module set for evaluating the container host system derivation.
- Speed up container destruction.\
Kill the container process instead of a clean shutdown.
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2018 The extra-container developers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: Makefile
================================================
all: test
test: runTests checkFlake
runTests:
nix shell -c sudo ./run-tests-in-container.sh
checkFlake:
nix flake check
doc:
nix run .#updateReadme
.PHONY: runTests checkFlake test doc
================================================
FILE: README.md
================================================
# extra-container
Manage declarative NixOS containers like imperative containers, without system
rebuilds.
Each declarative container adds a full system module evaluation to every NixOS rebuild,
which can be prohibitively slow for systems with many containers or when experimenting
with single containers.
On the other hand, the faster imperative containers lack the full range of options of declarative containers.
This tool brings you the best of both worlds.
## Example
```bash
sudo extra-container create --start <<'EOF'
{
containers.demo = {
privateNetwork = true;
hostAddress = "10.250.0.1";
localAddress = "10.250.0.2";
config = { pkgs, ... }: {
systemd.services.hello = {
wantedBy = [ "multi-user.target" ];
script = ''
while true; do
echo hello | ${pkgs.netcat}/bin/nc -lN 50
done
'';
};
networking.firewall.allowedTCPPorts = [ 50 ];
};
};
}
EOF
curl --http0.9 10.250.0.2:50 # Returns 'hello' from the container
# Now change the 'hello' string in the container definition to something
# else and re-run the `extra-container create --start` command.
# The container is automatically updated via NixOS' `switch-to-configuration`.
# The container is a regular container that can be controlled
# with nixos-container
nixos-container status demo
# Remove the container
sudo extra-container destroy demo
```
#### Run command in a container and exit
```bash
cfg='{
containers.demo.config = {
networking.hostName = "hello";
};
}'
extra-container shell -E "$cfg" --run c hostname # => hello
```
## Changelog
[`CHANGELOG.md`](CHANGELOG.md)
## Install
### On NixOS
##### NixOS ≥ 21.11
Add `programs.extra-container.enable = true` to your configuration.
##### Any NixOS with `flake` support
Import `extra-container.nixosModules.default` in your configuration.
### On other systemd-based Linux distros
```bash
git clone https://github.com/erikarvstedt/extra-container
# Calls sudo during install
extra-container/util/install.sh
```
[`install.sh`](util/install.sh) installs `extra-container` to the root nix user profile
and edits `/etc/sudoers` to enable running `extra-container` with sudo.
## More features
### Shell
Command `shell` starts a container shell session.
The shell provides helper functions for interacting with the container. The container is destroyed when exiting the shell.
This config uses `extra` options that are [explained below](#private-network-helper).
```bash
read -d '' src <<'EOF' || :
{
containers.demo = {
extra.addressPrefix = "10.250.0"; # Sets up a private network.
extra.enableWAN = true;
};
}
EOF
# Provide container config via `-E` instead of stdin because the shell's stdin
# should be connected to the terminal.
extra-container shell -E "$src" --ssh
```
`extra-container` automatically runs itself via `sudo` when called as a non-root user.
An example shell session
```
...
Starting shell.
Enter "h" for documentation.
$ h
Container address: 10.250.0.2 ($ip)
Container filesystem: /var/lib/nixos-containers/demo
Run "c COMMAND" to execute a command in the container
Run "c" to start a shell session inside the container
Run "cssh" for SSH
# Container internet access, enabled via option `extra.enableWAN`
$ c curl example.com
<!doctype html>
<html>
...
# Connect with SSH, enabled by `--ssh`
$ cssh hostname
demo
```
#### Run commands
Run a command in a shell session and exit. The container is destroyed afterwards.
```bash
cfg='{ containers.demo = {}; }'
extra-container shell -E "$cfg" --run c hostname
# => demo
```
Start a shell inside the container.
```bash
cfg='{ containers.demo = {}; }'
extra-container shell -E "$cfg" --run c
```
#### Repeated calls to `extra-container shell`
When `extra-container shell` detects that it is already running in a container shell
session, it updates the running container instead of destroying and restarting it and
starting a new shell.\
To prevent `sudo` from clearing the environment variables that are needed for shell
detection, call `extra-container` without `sudo`.\
`extra-container` will automatically run itself via `sudo` only when it is first
called as a non-root user outside of a shell session.
To force container destruction inside a shell session, use `extra-container shell --destroy|-d`.
#### Disable auto-destruction
By default, `shell` destroys the shell container before starting and before exiting.
This ensures that containers start with no leftover filesystem state from
previous runs and that containers do not consume system resources after use.\
To disable auto-destructing containers, run
`extra-container shell --no-destroy|-n`
### Private network helper
Container options `extra.*` are defined by `extra-container` and help with setting up private network containers.\
See [eval-config.nix](./eval-config.nix) for full option descriptions.
```nix
containers.demo = {
extra = {
# Sets
# privateNetwork = true
# hostAddress = "${addressPrefix}.1"
# localAddress = "${addressPrefix}.2"
addressPrefix = "10.250.0";
# Enable internet access for the container
enableWAN = true;
# Always allow connections from hostAddress
firewallAllowHost = true;
# Make the container's localhost reachable via localAddress
exposeLocalhost = true;
}
};
```
### Access working dir in non-file configs
`extra-container` appends `pwd` to `NIX_PATH` to allow configs given via `--expr|-E`
or via stdin to access the working directory.
```bash
extra-container create -E '{ imports = [ <pwd/myfile.nix> ]; ... }'
```
## Define containers via Flakes
See [examples/flake](./examples/flake).
## Usage
```
extra-container create <container-config-file>
[--attr|-A attrPath]
[--nixpkgs-path|--nixos-path path]
[--start|-s | --restart-changed|-r]
[--ssh]
[--build-args arg...]
<container-config-file> is a NixOS config file with container
definitions like 'containers.mycontainer = { ... }'
--attr | -A attrPath
Select an attribute from the config expression
--nixpkgs-path
A nix expression that returns a path to the nixpkgs source
to use for building the containers
--nixos-path
Like '--nixpkgs-path', but for directly specifying the NixOS source
--start | -s
Start all created containers
Update running containers that have changed or restart them if '--restart-changed' was specified
--update-changed | -u
Update running containers with a changed system configuration by running
'switch-to-configuration' inside the container.
Restart containers with a changed container configuration
--restart-changed | -r
Restart running containers that have changed
--ssh
Generate SSH keys in /tmp and enable container SSH access.
The key files remain after exit and are reused on subsequent runs.
Unlocks the function 'cssh' in 'extra-container shell'.
Requires container option 'privateNetwork = true'.
--build-args arg...
All following args are passed to nix-build.
Example:
extra-container create mycontainers.nix --restart-changed
extra-container create mycontainers.nix --nixpkgs-path \
'fetchTarball https://nixos.org/channels/nixos-unstable/nixexprs.tar.xz'
extra-container create mycontainers.nix --start --build-args --builders 'ssh://worker - - 8'
echo <container-config> | extra-container create
Read the container config from stdin
Example:
extra-container create --start <<EOF
{ containers.hello = { enableTun = true; config = {}; }; }
EOF
extra-container create --expr|-E <container-config>
Provide container config as an argument
extra-container create <store-path>
Create containers from <store-path>/etc
Examples:
Create from nixos system derivation
extra-container create /nix/store/9h..27-nixos-system-foo-18.03
Create from nixos etc derivation
extra-container create /nix/store/32..9j-etc
extra-container shell ...
Start a container shell session.
See the README for a complete documentation.
Supports all arguments from 'create'
Extra arguments:
--run <cmd> <arg>...
Run command in shell session and exit
Must be the last option given
--no-destroy|-n
Do not destroy shell container before and after running
--destroy|-d
If running inside an existing shell session, force container to
be destroyed before and after running
Example:
extra-container shell -E '{ containers.demo.config = {}; }'
extra-container build ...
Build the container config and print the resulting NixOS system etc path
This command can be used like 'create', but options related
to starting are not supported
extra-container list
List all extra containers
extra-container restart <container>...
Fixes the broken restart command of nixos-container (nixpkgs issue #43652)
extra-container destroy <container-name>...
Destroy containers
extra-container destroy <args for create/shell>...
Destroy the containers defined by the args for command `create` or `shell` (see above).
For this to work, the first arg after `destroy` must start with one of the
following three characters: ./-
Example:
extra-container destroy ./containers.nix
extra-container destroy --all|-a
Destroy all extra containers
extra-container <cmd> <arg>...
All other commands are forwarded to nixos-container
```
## Implementation
The script works like this: Take a NixOS config with container definitions and build
the system's `config.system.build.etc` derivation. Because we're not building a full
system we can use a reduced module set (`eval-config.nix`) to improve evaluation
performance.
Now link the container files from the etc derivation to the main system, like so:
```
nixos-system/etc/systemd/system/container@CONTAINER.service -> /etc/systemd-mutable/system
nixos-system/etc/containers/CONTAINER.conf -> /etc/containers (system.stateVersion < 22.05)
-> /etc/nixos-containers (system.stateVersion ≥ 22.05)
```
Finally, add gcroots pointing to the linked files.
## Developing
All contributions and suggestions are welcome, even if they're minor or cosmetic.
### Development workflow
Run `nix develop` in the project root directory to start a development shell.\
Within the shell, you can run extra-container from the [local
source](./extra-container) via command `extra-container`.
When changing the `Usage` documentation in `extra-container`, run `make doc` to copy
these changes to `README.md`.
#### Tests
Run `make` to run the tests.\
The following tests are executed:
- Main test suite
Can be run manually via `sudo run-tests-in-container.sh`.\
This script creates a temporary container named `test-extra-container` in which
the main test script [`test.sh`](./test.sh) is run.\
`test.sh` adds and removes temporary containers named `test-*` on the host system.
It can also be called directly, but wrapping it with `run-tests-in-container.sh`
helps reducing interference with your main system.
- VM test
Can be run manually via `nix build .#test`.\
This is a basic test using the [NixOS VM test
framework](https://github.com/NixOS/nixpkgs/blob/master/nixos/lib/testing-python.nix).
It is built as a Nix derivation, which makes it independent from the system
environment.
For debugging the VM, run `nix run .#debugTest` to start a Python test driver shell
inside the VM.
- `nix flake check`
Evaluates all flake outputs and builds the VM test.
#### VM
Run `nix run .#vm` to start a VM where `extra-container` is installed.\
This provides an isolated and reproducible testing environment.
================================================
FILE: default.nix
================================================
{ stdenv, lib, nixos-container, openssh, glibcLocales
, pkgSrc ? lib.cleanSource ./.
}:
stdenv.mkDerivation rec {
pname = "extra-container";
version = "0.14";
src = pkgSrc;
buildCommand = ''
install -D $src/extra-container $out/bin/extra-container
patchShebangs $out/bin
share=$out/share/extra-container
install $src/eval-config.nix -Dt $share
# Use existing PATH for systemctl and machinectl
scriptPath="export PATH=${lib.makeBinPath [ openssh ]}:\$PATH"
sed -i "
s|evalConfig=.*|evalConfig=$share/eval-config.nix|
s|LOCALE_ARCHIVE=.*|LOCALE_ARCHIVE=${glibcLocales}/lib/locale/locale-archive|
2i$scriptPath
2inixosContainer=${nixos-container}/bin
" $out/bin/extra-container
'';
meta = with lib; {
description = "Run declarative containers without full system rebuilds";
homepage = "https://github.com/erikarvstedt/extra-container";
changelog = "https://github.com/erikarvstedt/extra-container/blob/master/CHANGELOG.md";
license = licenses.mit;
platforms = platforms.linux;
maintainers = [ maintainers.erikarvstedt ];
};
}
================================================
FILE: eval-config.nix
================================================
{ nixosPath
, systemConfig
, legacyInstallDirs
, system ? builtins.currentSystem
}:
let
# A minimal module set for evaluating container configs.
# This significantly reduces extra-container evaluation overhead (total eval time - container eval time)
# Compatible with nixpkgs >= 16.09
baseModules = [
(nixosPath + "/modules/misc/assertions.nix")
(nixosPath + "/modules/misc/nixpkgs.nix")
(nixosPath + "/modules/misc/extra-arguments.nix")
(nixosPath + "/modules/system/activation/top-level.nix")
(nixosPath + "/modules/system/etc/etc.nix")
(nixosPath + "/modules/system/boot/systemd.nix")
nixosContainerModule
dummyOptions
];
nixosContainerModule = let
new = nixosPath + "/modules/virtualisation/nixos-containers.nix";
old = nixosPath + "/modules/virtualisation/containers.nix"; # For nixpkgs < 20.09)
in
if builtins.pathExists new then new else old;
dummyOptions = { pkgs, lib, options, ... }: let
optionValue = default: lib.mkOption { inherit default; };
dummy = optionValue [];
in {
options = {
boot.kernel.sysctl = dummy;
boot.kernelModules = dummy;
boot.kernelPackages.kernel.version = optionValue "";
boot.kernelParams = dummy;
boot.loader.systemd-boot.bootCounting.enable = optionValue false;
environment.systemPackages = dummy;
networking.dhcpcd.denyInterfaces = dummy;
networking.hosts = dummy;
networking.extraHosts = dummy;
networking.proxy.envVars = optionValue {};
nix.package = optionValue pkgs.nix;
security = dummy;
services = {
dbus = dummy;
logrotate = dummy;
udev = dummy;
rsyslogd.enable = optionValue false;
syslog-ng.enable = optionValue false;
};
system.activationScripts = dummy;
system.fsPackages = dummy;
system.nssDatabases = dummy;
system.nssModules = dummy;
system.path = optionValue "";
system.requiredKernelConfig = dummy;
system.stateVersion = optionValue (if legacyInstallDirs then "21.11" else "22.05");
systemd.oomd = dummy;
systemd.user.generators = optionValue {};
ids.gids.keys = dummy;
ids.uids.systemd-coredump = dummy;
ids.gids.systemd-journal = dummy;
ids.gids.systemd-journal-gateway = dummy;
ids.uids.systemd-journal-gateway = dummy;
ids.gids.systemd-network = dummy;
ids.uids.systemd-network = dummy;
ids.uids.systemd-resolve = dummy;
ids.gids.systemd-resolve = dummy;
users.users.systemd-coredump = dummy;
users.users.systemd-network.group = dummy;
users.users.systemd-network.uid = dummy;
users.users.systemd-resolve.group = dummy;
users.users.systemd-resolve.uid = dummy;
users.users.systemd-journal-gateway.group = dummy;
users.users.systemd-journal-gateway.uid = dummy;
users.groups.systemd-coredump = dummy;
users.groups.systemd-network.gid = dummy;
users.groups.systemd-resolve.gid = dummy;
users.groups.keys.gid = dummy;
users.groups.systemd-journal.gid = dummy;
users.groups.systemd-journal-gateway.gid = dummy;
};
config = {
systemd.timers = lib.mkForce {};
systemd.targets = lib.mkForce {};
} // lib.optionalAttrs (options.systemd ? managerEnvironment) {
systemd.managerEnvironment = lib.mkForce {};
};
};
containerAssert = cond: name: msg: value:
if cond then value
else throw "container '${name}': ${msg}'";
assertNonNull = var:
containerAssert (var != null);
extraModule = { config, pkgs, lib, ... }: with lib; {
options = {
containers = mkOption {
type = types.attrsOf (types.submodule (
{ config, name, ... }: {
options = {
extra = {
addressPrefix = mkOption {
type = with types; nullOr str;
default = null;
description = ''
Enable privateNetwork and set
hostAddress = <addressPrefix>.1
localAddress = <addressPrefix>.2
'';
};
enableWAN = mkOption {
type = types.bool;
default = false;
description = ''
Enable WAN access inside the container by rewriting container traffic
to use the host's address (NAT).
Only active when privateNetwork == true.
'';
};
exposeLocalhost = mkOption {
type = types.bool;
default = false;
description = ''
Forward requests from the container's external interface
to the container's localhost.
Useful to test internal services from outside the container.
WARNING: This exposes the container's localhost to all users.
Only use in a trusted environment.
Only active when privateNetwork == true.
'';
};
firewallAllowHost = mkOption {
type = types.bool;
default = false;
description = ''
Always allow connections from the container host.
Only active when privateNetwork == true.
'';
};
enableSSH = mkOption {
type = types.bool;
default = builtins.getEnv("extraContainerSSH") == "1";
description = ''
Enable SSH access with an automatically generated key.
This enables the 'cssh' comand in extra-container shell.
Requires privateNetwork == true.
'';
};
};
};
config = mkMerge [
(
let
prefix = config.extra.addressPrefix;
in mkIf (prefix != null) {
privateNetwork = true;
hostAddress = "${prefix}.1";
localAddress = "${prefix}.2";
}
)
{
config = ({ pkgs, ... }@moduleArgs: mkMerge [
{
systemd.services.forward-to-localhost = mkIf (config.extra.exposeLocalhost && config.privateNetwork) {
wantedBy = [ "network.target" ];
script = assertNonNull config.localAddress name
''
option extra.exposeLocalhost requires localAddress to be non-null.
''
''
${pkgs.procps}/bin/sysctl -w net.ipv4.conf.all.route_localnet=1
${pkgs.iptables}/bin/iptables -w -t nat -I PREROUTING -p tcp \
-d ${config.localAddress} ! --dport 80 -j DNAT --to-destination 127.0.0.1
'';
};
networking.firewall.extraCommands = mkIf (config.extra.firewallAllowHost && config.privateNetwork) (
assertNonNull config.hostAddress name
''
option extra.exposeLocalhost requires hostAddress to be non-null.
''
''
iptables -w -A nixos-fw -s ${config.hostAddress} -j ACCEPT
''
);
# Silence system state warning
system.stateVersion = lib.mkDefault moduleArgs.config.system.nixos.release;
}
(mkIf config.extra.enableSSH {
services.openssh.enable = containerAssert config.privateNetwork name ''
option extra.enableSSH requires privateNetwork to be enabled.
'' true;
users.users.root.openssh.authorizedKeys.keyFiles = [
/tmp/extra-container-ssh/key.pub
];
})
]);
}
];
}
));
};
};
config = {
systemd.services = let
WANContainers = builtins.filter (c:
let cfg = config.containers.${c};
in cfg.privateNetwork && cfg.extra.enableWAN
) (builtins.attrNames config.containers);
iptables = "${pkgs.iptables}/bin/iptables";
serviceCfg = c: let
containerAddress = config.containers.${c}.localAddress;
in
assertNonNull containerAddress c
''
option extra.enableWAN requires localAddress to be non-null
''
{
preStart = "${iptables} -w -t nat -A POSTROUTING -s ${containerAddress} -j MASQUERADE";
postStop = "${iptables} -w -t nat -D POSTROUTING -s ${containerAddress} -j MASQUERADE || true";
};
in
listToAttrs (map (c: nameValuePair "container@${c}" (serviceCfg c)) WANContainers);
};
};
in
import (nixosPath + "/lib/eval-config.nix") {
inherit baseModules system;
modules = [ extraModule systemConfig ];
}
================================================
FILE: examples/flake/flake.nix
================================================
# See how this flake is used in ./usage.sh
{
inputs.extra-container.url = "github:erikarvstedt/extra-container";
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
outputs = { extra-container, ... }@inputs:
extra-container.lib.eachSupportedSystem (system: {
packages.default = extra-container.lib.buildContainers {
# The system of the container host
inherit system;
# Only set this if the `system.stateVersion` of your container
# host is < 22.05
# legacyInstallDirs = true;
# Optional: Set nixpkgs.
# If unset, the nixpkgs input of extra-container flake is used
nixpkgs = inputs.nixpkgs;
# Set this to disable `nix run` support
# addRunner = false;
config = {
containers.demo = {
extra.addressPrefix = "10.250.0";
# `specialArgs` is available in nixpkgs > 22.11
# This is useful for importing flakes from modules (see nixpkgs/lib/modules.nix).
# specialArgs = { inherit inputs; };
config = { pkgs, ... }: {
systemd.services.hello = {
wantedBy = [ "multi-user.target" ];
script = ''
while true; do
echo hello | ${pkgs.netcat}/bin/nc -lN 50
done
'';
};
networking.firewall.allowedTCPPorts = [ 50 ];
};
};
};
};
});
}
================================================
FILE: examples/flake/usage.sh
================================================
# Usage via `nix run`
#―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
# Container lifecycle
# Create and start container defined by ./flake.nix
nix run . -- create --start
# You can use the same command to update the (running) container,
# after changing the container configuration.
#
# The arguments after `--` are passed to the `extra-container` binary in PATH,
# while the flake is used for the container definitions.
# Use `nixos-container` to control the running container
sudo nixos-container run demo -- hostname
sudo nixos-container root-login demo
# Destroy container
nix run . -- destroy
#―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
# Container shell
# Start an interactive shell in an ephemeral container
nix run . -- shell
nix run . # equivalent, because `shell` is used as the default command
# Run a single command in the container.
# The container is destroyed afterwards.
nix run . -- --run c hostname
nix run . -- shell --run c hostname # equivalent
nix run . -- --run bash -c 'curl --http0.9 $ip:50'
#―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
# Usage via `nix build`
# 1. Build container
nix build . --out-link /tmp/container
# 2. Run container
extra-container shell /tmp/container
#―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
# Inspect container configs
nix eval . --apply 'sys: sys.containers.demo.config.networking.hostName'
================================================
FILE: extra-container
================================================
#!/usr/bin/env bash
set -eo pipefail
shopt -s nullglob
# This script uses systemctl, machinectl and nsenter from the existing environment
showHelp() {
echo "Usage:"
echo
echo "extra-container create <container-config-file>"
echo " [--attr|-A attrPath]"
echo " [--nixpkgs-path|--nixos-path path]"
echo " [--start|-s | --restart-changed|-r]"
echo " [--ssh]"
echo " [--build-args arg...]"
echo
echo " <container-config-file> is a NixOS config file with container"
echo " definitions like 'containers.mycontainer = { ... }'"
echo
echo " --attr | -A attrPath"
echo " Select an attribute from the config expression"
echo
echo " --nixpkgs-path"
echo " A nix expression that returns a path to the nixpkgs source"
echo " to use for building the containers"
echo
echo " --nixos-path"
echo " Like '--nixpkgs-path', but for directly specifying the NixOS source"
echo
echo " --start | -s"
echo " Start all created containers"
echo " Update running containers that have changed or restart them if '--restart-changed' was specified"
echo
echo " --update-changed | -u"
echo " Update running containers with a changed system configuration by running"
echo " 'switch-to-configuration' inside the container."
echo " Restart containers with a changed container configuration"
echo
echo " --restart-changed | -r"
echo " Restart running containers that have changed"
echo
echo " --ssh"
echo " Generate SSH keys in /tmp and enable container SSH access."
echo " The key files remain after exit and are reused on subsequent runs."
echo " Unlocks the function 'cssh' in 'extra-container shell'."
echo " Requires container option 'privateNetwork = true'."
echo
echo " --build-args arg..."
echo " All following args are passed to nix-build."
echo
echo " Example:"
echo " extra-container create mycontainers.nix --restart-changed"
echo
echo " extra-container create mycontainers.nix --nixpkgs-path \\"
echo " 'fetchTarball https://nixos.org/channels/nixos-unstable/nixexprs.tar.xz'"
echo
echo " extra-container create mycontainers.nix --start --build-args --builders 'ssh://worker - - 8'"
echo
echo "echo <container-config> | extra-container create"
echo " Read the container config from stdin"
echo
echo " Example:"
echo " extra-container create --start <<EOF"
echo " { containers.hello = { enableTun = true; config = {}; }; }"
echo " EOF"
echo
echo "extra-container create --expr|-E <container-config>"
echo " Provide container config as an argument"
echo
echo "extra-container create <store-path>"
echo " Create containers from <store-path>/etc"
echo
echo " Examples:"
echo " Create from nixos system derivation"
echo " extra-container create /nix/store/9h..27-nixos-system-foo-18.03"
echo
echo " Create from nixos etc derivation"
echo " extra-container create /nix/store/32..9j-etc"
echo
echo "extra-container shell ..."
echo " Start a container shell session."
echo " See the README for a complete documentation."
echo " Supports all arguments from 'create'"
echo
echo " Extra arguments:"
echo " --run <cmd> <arg>..."
echo " Run command in shell session and exit"
echo " Must be the last option given"
echo " --no-destroy|-n"
echo " Do not destroy shell container before and after running"
echo " --destroy|-d"
echo " If running inside an existing shell session, force container to"
echo " be destroyed before and after running"
echo
echo " Example:"
echo " extra-container shell -E '{ containers.demo.config = {}; }'"
echo
echo "extra-container build ..."
echo " Build the container config and print the resulting NixOS system etc path"
echo
echo " This command can be used like 'create', but options related"
echo " to starting are not supported"
echo
echo "extra-container list"
echo " List all extra containers"
echo
echo "extra-container restart <container>..."
echo " Fixes the broken restart command of nixos-container (nixpkgs issue #43652)"
echo
echo "extra-container destroy <container-name>..."
echo " Destroy containers"
echo
echo "extra-container destroy <args for create/shell>..."
echo " Destroy the containers defined by the args for command \`create\` or \`shell\` (see above)."
echo " For this to work, the first arg after \`destroy\` must start with one of the"
echo " following three characters: ./-"
echo
echo " Example:"
echo " extra-container destroy ./containers.nix"
echo
echo "extra-container destroy --all|-a"
echo " Destroy all extra containers"
echo
echo "extra-container <cmd> <arg>..."
echo " All other commands are forwarded to nixos-container"
exit 0
}
case $1 in
help|-h|--help)
showHelp
;;
esac
# If a container build output is given ($EXTRA_CONTAINER_ETC) and no command is
# specified in $1, use `shell` as the default command
if [[ $EXTRA_CONTAINER_ETC && ($# == 0 || $1 == -*) ]]; then
set -- shell "$@"
elif [[ $# == 0 ]]; then
showHelp
fi
# Run as root if needed
#------------------------------------------------------------------------------
if [[ $EUID != 0 ]]; then
exec sudo PATH="$PATH" NIX_PATH="$NIX_PATH" EXTRA_CONTAINER_ETC="$EXTRA_CONTAINER_ETC" "${BASH_SOURCE[0]}" "$@"
fi
# Operating system specific setup
#------------------------------------------------------------------------------
errEcho() {
>&2 echo "$@"
}
[[ -e /run/booted-system/nixos-version ]] && isNixOS=1 || isNixOS=
if [[ $isNixOS ]]; then
mutableServicesDir=/etc/systemd-mutable/system
else
# This is the canonical way to check for systemd
# https://www.freedesktop.org/software/systemd/man/sd_booted.html
if [[ ! -e /run/systemd/system ]]; then
errEcho "extra-container requires systemd"
exit 1
fi
if ! type -P machinectl > /dev/null; then
errEcho "extra-container requires machinectl to be installed"
exit 1
fi
if [[ ! -e /nix/var/nix/profiles/default ]]; then
errEcho "extra-container requires a multi-user nix installation"
exit 1
fi
mutableServicesDir=/usr/lib/systemd/system
# For nixos-container on non-NixOS systems https://github.com/NixOS/nix/issues/599#issuecomment-153885553
# The value of 'LOCALE_ARCHIVE' is inserted by the builder (./default.nix)
if [[ ! -v LOCALE_ARCHIVE ]]; then
export LOCALE_ARCHIVE=
fi
# Work around https://github.com/NixOS/nixpkgs/issues/28833
createOsReleaseFile() {
if [[ ! -f /etc/static/os-release ]]; then
mkdir -p /etc/static
echo "# added by extra-container" > /etc/static/os-release
fi
}
fi
# Use existing `nixos-container` in PATH by default because on NixOS the container
# install location differs depending on `system.stateVersion` and is hardcoded in
# the `nixos-container` binary.
# See also: function `getInstallDirs`.
if ! type -P nixos-container > /dev/null; then
PATH=$nixosContainer:$PATH
fi
# Parse and run command
#------------------------------------------------------------------------------
trap 'echo "Error at ${BASH_SOURCE[0]}:$LINENO"' ERR
tmpDir=
onlyBuild=
shell=
list=
restart=
destroy=
case $1 in
create|add)
shift
;;
build)
onlyBuild=1
shift
;;
shell)
shell=1
shift
;;
list)
list=1
;;
restart)
restart=1
shift
;;
destroy)
destroy=1
shift
;;
*)
exec nixos-container "$@"
;;
esac
# Value is replaced by builder (./default.nix)
evalConfig=$(cd "${BASH_SOURCE[0]%/*}" && pwd)/eval-config.nix
buildContainers() {
local containerCfg=$1
local tmpDir=$2
local nixosPath=$3
local attrExpr=
if [[ $attr ]]; then
attrExpr=".\${''$attr''}"
fi
NIX_PATH=$NIX_PATH:pwd=$PWD nix-build --out-link $tmpDir/result "${buildArgs[@]}" -E \
" let cfg = ($containerCfg)$attrExpr;
in (import $evalConfig {
nixosPath = $nixosPath;
legacyInstallDirs = $legacyInstallDirs;
systemConfig = cfg;
}).config.system.build.etc
" >/dev/null
}
restartContainers() {
local services=$(getServiceNames $*)
# `systemctl restart <container>` is broken (https://github.com/NixOS/nixpkgs/issues/43652),
# so use a workaraound
systemctl stop $services
# Retry terminating the container machines until the command succeeds
# or the machines have disappeared
for ((i = 1;; i++)); do
local failed=
local output
output=$(machinectl terminate $* 2>&1) || failed=1
if [[ ! $failed || $output == *no*machine*known* ]]; then
break
fi
echo $output
if ((i == 20)); then
errEcho "Failed to stop containers."
exit 1
fi
sleep 0.001
done
systemctl start $services
}
updateContainers() {
for container in $*; do
local confFile=$configDirectory/$container.conf
local systemPath=$(grep -ohP "(?<=^SYSTEM_PATH=).*" $confFile)
# Shift output 2 spaces to the right
echo " Updating $container"
nixos-container run $container -- bash -lc "${systemPath}/bin/switch-to-configuration test" |& sed 's/^/ /' || true
echo
done
}
getContainers() {
for service in $mutableServicesDir/container@?*.service; do
getContainerName $service
done
}
getContainerName() {
[[ $1 =~ container@(.+)\.service ]]
echo ${BASH_REMATCH[1]}
}
getServiceNames() {
for container in $*; do
echo "container@$container.service "
done
}
makeGCRootsPath() {
# Use gcroots/auto instead of gcroots/per-user/root because
# stale links in per-user are not automatically removed.
# This avoids cluttering the gcroots dir in case containers
# are manually removed.
echo /nix/var/nix/gcroots/auto/extra-container-$1
}
# The key is reused between extra-container sessions
makeSSHKey() {
local keyDir=/tmp/extra-container-ssh
sshKey=$keyDir/key
if [[ ! (-d $keyDir && $(stat -c '%a%U' $keyDir) == 700root &&
-f $sshKey && -f $sshKey.pub) ]]; then
if [[ -e $keyDir ]]; then
rm -rf $keyDir
fi
mkdir -m 700 $keyDir
ssh-keygen -t ed25519 -P '' -C none -f $sshKey > /dev/null
fi
chmod 600 $sshKey
}
makeTmpDir() {
if [[ ! $tmpDir ]]; then
tmpDir=$(mktemp -d /tmp/extra-container.XXX)
fi
}
configDirectory=
getInstallDirs() {
if [[ ! $configDirectory ]]; then
nixosContainer=$(type -p nixos-container)
if grep -q '"/etc/nixos-containers"' "$nixosContainer"; then
# NixOS ≥22.05
configDirectory=/etc/nixos-containers
configDirectoryName=nixos-containers
otherConfigDirectoryName=containers
stateDirectory=/var/lib/nixos-containers
legacyInstallDirs=false
else
# NixOS <22.05
configDirectory=/etc/containers
configDirectoryName=containers
otherConfigDirectoryName=nixos-containers
stateDirectory=/var/lib/containers
legacyInstallDirs=true
fi
fi
}
# This fn is used by commands `create`, `shell`, `destroy`.
# To simplify the implementation, this fn includes the whole arg parsing for commands
# `create`, `shell`. These args are not by `destroy`.
getContainersFromDefinition() {
start=
update=
restart=
ssh=
containerExpr=
attr=
nixpkgsPath=
nixosPath=
buildArgs=()
runCommand=()
destroy=1
forceDestroy=
args=()
while [[ $# > 0 ]]; do
arg="$1"
shift
case $arg in
--start|-s)
start=1
;;
--update-changed|-u)
update=1
;;
--restart-changed|-r)
restart=1
;;
--ssh)
ssh=1
;;
--expr|-E)
containerExpr="$1"
shift
;;
--attr|-A)
attr="$1"
shift
;;
--nixpkgs-path)
nixpkgsPath="$1"
shift
;;
--nixos-path)
nixosPath="$1"
shift
;;
--build-args)
buildArgs=()
# Add all following args until --run
while [[ $# > 0 && $1 != --run ]]; do
buildArgs+=("$1")
shift
done
;;
--run)
runCommand=("$@")
break
;;
--no-destroy|-n)
destroy=
;;
--destroy|-d)
destroy=1
forceDestroy=1
;;
*)
args+=("$arg")
;;
esac
done
set -- "${args[@]}"
if [[ ! $containerExpr ]]; then
arg=${1:-}
shift || true
fi
if [[ $# > 0 ]]; then
errEcho "Error: Unhandled arguments: $*"
exit 1
fi
## 1. Build containers if needed
if [[ $ssh ]]; then
makeSSHKey
fi
# This env var is read by ./eval-config.nix while building
export extraContainerSSH=$ssh
getInstallDirs
needToBuildContainers() {
if [[ $containerExpr ]]; then
return 0;
fi
if [[ $arg ]]; then
if [[ -f $arg || -e $arg/default.nix ]]; then
return 0
fi
elif [[ ! $EXTRA_CONTAINER_ETC ]]; then
return 0
fi
return 1
}
if needToBuildContainers; then
if [[ ! $containerExpr ]]; then
if [[ ! $arg || $arg == - ]]; then
# Read container cfg from stdin
if [[ $shell ]]; then
errEcho "Reading container config from STDIN is not supported for command 'shell'"
exit 1
fi
read -d '' containerExpr || true
else
containerExpr="import ''$(realpath "$arg")''"
fi
fi
if [[ ! $nixosPath ]]; then
if [[ $nixpkgsPath ]]; then
nixosPath="\"\${toString ($nixpkgsPath)}/nixos\""
else
nixosPath="<nixpkgs/nixos>"
fi
fi
makeTmpDir
errEcho "Building containers..."
buildContainers "$containerExpr" $tmpDir "$nixosPath"
nixosSystemEtc=$tmpDir/result/etc
if [[ $onlyBuild ]]; then
realpath $tmpDir/result
exit 0
fi
else
if [[ $arg ]]; then
EXTRA_CONTAINER_ETC=$arg
fi
if [[ ! $EXTRA_CONTAINER_ETC ]]; then
errEcho "No containers specified"
exit 1
fi
nixosSystemEtc="$EXTRA_CONTAINER_ETC/etc"
if [[ ! -e $nixosSystemEtc ]]; then
errEcho "$nixosSystemEtc doesn't exist"
exit 1
fi
fi
## 2. Gather containers
services=$(echo $nixosSystemEtc/systemd/system/container@?*.service)
if [[ ! $services ]]; then
errEcho "No container services in $nixosSystemEtc/systemd/system"
exit 0
fi
allContainers=()
for service in $services; do
allContainers+=($(getContainerName $service))
done
allContainers=($(printf '%s\n' ${allContainers[@]} | sort))
}
atExit() {
origExitCode=$?
set +e
if [[ $startShellEnv && $destroy ]]; then
[[ $runCommand ]] || echo "Destroying container."
destroyContainers $shellContainer
fi
if [[ $tmpDir ]]; then
rm -rf $tmpDir
fi
exit $origExitCode
}
trap atExit EXIT
# Command 'list extra containers'
#------------------------------------------------------------------------------
if [[ $list ]]; then
getContainers
exit 0
fi
# Command 'restart container'
#------------------------------------------------------------------------------
if [[ $restart ]]; then
restartContainers $*
exit 0
fi
# Command 'destroy containers'
#------------------------------------------------------------------------------
systemctlIgnoreNotLoaded() {
local failed=
output=$(systemctl "$@" 2>&1) || failed=1
if [[ $failed ]]; then
if [[ $output == *not\ loaded* ]]; then
unitLoaded=
else
return 1
fi
fi
}
destroyContainers() {
getInstallDirs
if [[ ! -e "$configDirectory" ]]; then
# No containers installed. The containers dir is needed by the code below.
return
fi
local needDaemonReload=
for container in "$@"; do
service=container@${container}.service
serviceFile=${mutableServicesDir}/$service
confFile="$configDirectory/$container.conf"
# Signal 'stop' before killing so that the killed container doesn't restart
local output
local unitLoaded=1
if ! systemctlIgnoreNotLoaded stop --no-block $service; then
errEcho "Stopping $service failed with: $output"
fi
if [[ $unitLoaded ]]; then
if ! systemctlIgnoreNotLoaded kill $service; then
errEcho "Killing $service failed with: $output"
fi
fi
rm -f "$mutableServicesDir/machines.target.wants/$service"
if [[ -L $serviceFile ]]; then
rm $serviceFile
needDaemonReload=1
fi
local gcRootsPath=$(makeGCRootsPath $container)
rm -f $gcRootsPath
rm -f $gcRootsPath.conf
# Remove declarative container confFile, otherwise `nixos-container` fails
# with 'cannot destroy declarative container'
rm -f $confFile
# Create dummy confFile, otherwise `nixos-container` stops before
# destroying the container completely
touch $confFile
# Remove immutable attribute from nested container var/empty files so that
# the container directory can be deleted
for varempty in "$stateDirectory"/$container/var/lib/*containers/*/var/empty; do
chattr -i $varempty
done
nixos-container destroy $container || true
done
if [[ $needDaemonReload ]]; then
systemctl daemon-reload
fi
}
if [[ $destroy ]]; then
if [[ $1 == --all || $1 == -a ]]; then
getInstallDirs
containers=($(getContainers))
# If the first letter of the first arg is one of ./-
# or no arg is given and EXTRA_CONTAINER_ETC is defined
elif (($# > 0)) && [[ ${1:0:1} == [./-] ]] || [[ $EXTRA_CONTAINER_ETC ]]; then
# Get containers to be destroyed from the container definition
getContainersFromDefinition "$@"
containers=("${allContainers[@]}")
echo "Destroying containers:"
printf '%s\n' "${containers[@]}"
else
if (($# == 0)); then
errEcho "No container name specified"
exit 1
fi
containers=("$@")
fi
destroyContainers "${containers[@]}"
exit 0
fi
# Commands 'create', 'shell'
#------------------------------------------------------------------------------
getContainersFromDefinition "$@"
## Shell related setup
startShellEnv=
makeStartInterruptible=
if [[ $shell ]]; then
if (( ${#allContainers[@]} > 1 )); then
errEcho "Command 'shell' requires only one container to be defined"
exit 1
fi
shellContainer=${allContainers[0]}
[[ $shellContainer == $runningShellContainer ]] && insideContainerShell=1 || insideContainerShell=
if [[ $runCommand || ! $insideContainerShell ]]; then
startShellEnv=1
fi
if [[ $insideContainerShell && ! $forceDestroy ]]; then
destroy=
fi
if [[ $startShellEnv && ! $runCommand ]]; then
makeStartInterruptible=1
fi
if [[ $destroy ]]; then
destroyContainers $shellContainer
fi
start=1
fi
## 4. Install containers
confWithoutSystem() {
sed /^SYSTEM_PATH=/d $1
}
isContainerUnchanged() {
[[ -e $serviceDest && \
-e $confDest && \
$(realpath $serviceFile) == $(realpath $serviceDest) ]] || return 1
if [[ $(realpath $confFile) != $(realpath $confDest) ]]; then
if [[ $(confWithoutSystem $confFile) == $(confWithoutSystem $confDest) ]]; then
onlySystemChangedContainers+=($container)
fi
return 1
fi
}
checkInstallationSuccess() {
if [[ $isNixOS ]]; then
service=container@$1.service
# If ExecStart points to the generic 'container_-start' script of the
# 'container@.service' service template, the installation of the
# container service file failed.
if [[ $(systemctl show -p ExecStart $service) == */bin/container_-start* ]]; then
errEcho
errEcho 'Container service installation failed.'
errEcho 'Please add the following to your NixOS configuration'
errEcho 'to enable dynamically installing systemd units:'
errEcho 'boot.extraSystemdUnitPaths = [ "/etc/systemd-mutable/system" ];'
errEcho
errEcho 'See also: https://github.com/erikarvstedt/extra-container/#install'
exit 1
fi
fi
}
echo
echo "Installing containers:"
mkdir -p $mutableServicesDir "$configDirectory" /nix/var/nix/gcroots/auto/
if [[ ! $isNixOS ]]; then createOsReleaseFile; fi
changedContainers=()
onlySystemChangedContainers=()
for container in ${allContainers[@]} ; do
service=container@$container.service
serviceFile=$nixosSystemEtc/systemd/system/$service
serviceDest=$mutableServicesDir/$service
confFile=$nixosSystemEtc/$configDirectoryName/$container.conf
confDest="$configDirectory/$(basename $confFile)"
if isContainerUnchanged; then
echo "$container (unchanged, skipped)"
else
echo "$container"
changedContainers+=($container)
if [[ ! -e $confFile ]]; then
alternativeConfFile=$nixosSystemEtc/$otherConfigDirectoryName/$container.conf
if [[ -e $alternativeConfFile ]]; then
errEcho
errEcho 'Error:'
errEcho 'To be compatible with this container host, the container'
errEcho -n 'must be built with `legacyInstallDirs = '
if [[ $legacyInstallDirs == true ]]; then
errEcho 'true`.'
else
errEcho 'false` (default).'
fi
errEcho
else
errEcho "Error: $confFile doesn't exist"
fi
exit 1
fi
ln -sf $(realpath $serviceFile) $serviceDest
ln -sf $(realpath $confFile) $confDest
gcRootsPath=$(makeGCRootsPath $container)
ln -sf $serviceDest $gcRootsPath
ln -sf $confDest $gcRootsPath.conf
if grep -q AUTO_START=1 "$confFile"; then
wantsDir=$mutableServicesDir/machines.target.wants
mkdir -p "$wantsDir"
ln -sfn "../$service" "$wantsDir"
fi
fi
done
if [[ $changedContainers ]]; then
systemctl daemon-reload
checkInstallationSuccess ${changedContainers[0]}
fi
## 3. Setup shell
if [[ $makeStartInterruptible ]]; then
# When starting a shell, continue the script when container starting gets interrupted.
# This way, the user can interrupt waiting for the startup of all services
# and interact with the starting container.
trap handleShell SIGINT
fi
handleShell() {
trap - SIGINT
export containerIp=$(nixos-container show-ip $shellContainer 2> /dev/null) || containerIp=
export runningShellContainer=$shellContainer
export containerStateDirectory=$stateDirectory
extraContainerCmd() {
if [[ $# > 0 ]]; then
nixos-container run $name -- "$@" | cat;
else
nixos-container root-login $name
fi
}
if [[ $extraContainerSSH ]]; then
makeTmpDir
export sshKey
export sshControlPath=$tmpDir/ssh-connection
extraContainerSSHCmd() {
ssh -i $sshKey -o ConnectTimeout=2 \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
-o ControlMaster=auto -o ControlPath="$sshControlPath" -o ControlPersist=60 \
root@$ip "$@"
}
export -f extraContainerSSHCmd
fi
extraContainerHelp() {
if [[ $ip ]]; then
echo "Container address: $ip (\$ip)"
fi
echo "Container filesystem: $containerStateDirectory/$name"
echo
echo 'Run "c COMMAND" to execute a command in the container'
echo 'Run "c" to start a shell session inside the container'
if [[ $extraContainerSSH ]]; then
echo 'Run "cssh" for SSH'
fi
}
export -f extraContainerCmd extraContainerHelp
defineShortcuts='
function h() { extraContainerHelp; }
function c() { extraContainerCmd "$@"; }
export -f h c
if [[ $extraContainerSSH ]]; then
function cssh() { extraContainerSSHCmd "$@"; }
export -f cssh
fi
export name=$runningShellContainer
export ip=$containerIp
'
if [[ $runCommand ]]; then
eval "$defineShortcuts"
echo "Running command."
"${runCommand[0]}" "${runCommand[@]:1}"
else
echo 'Starting shell.'
echo 'Enter "h" for documentation.'
# Start interactive, non-login shell.
# Contrary to what is stated in the manual, bash --rcfile sources
# /etc/profile (and consequently .bashrc) before evaluating the custom rcfile.
# This allows us to start a shell with prompt and aliases set up and add extra
# features via --rcfile.
bash --rcfile <(
# Prevent aliases from overriding the helper functions
echo "unalias h c cssh 2>/dev/null; $defineShortcuts"
# Use PATH from the current calling environment
printf 'export PATH=%q' "$PATH"
)
fi
# Needed when called from the SIGINT handler
exit 0
}
## 4. Start/restart containers
echo
if [[ $start || $update || $restart ]]; then
toStart=()
toRestart=()
# systemctl is-active fails when some containers are not active
statuses=($(systemctl is-active $(getServiceNames ${allContainers[@]}))) || true
for i in ${!statuses[@]}; do
if [[ ${statuses[$i]} == active ]]; then
runningContainers+=(${allContainers[$i]})
else
toStart+=(${allContainers[$i]})
fi
done
if [[ $start && ((${#toStart[@]} != 0)) ]]; then
echo "Starting containers:"
printf '%s\n' ${toStart[@]}
echo
systemctl start $(getServiceNames ${toStart[@]})
fi
# Update or restart changed containers that are running
toRestart=($(comm -12 <(printf '%s\n' ${runningContainers[@]} | sort) \
<(printf '%s\n' ${changedContainers[@]})))
if ((${#toRestart[@]} != 0)); then
if [[ ! $restart ]]; then
toUpdate=($(comm -12 <(printf '%s\n' ${toRestart[@]}) \
<(printf '%s\n' ${onlySystemChangedContainers[@]})))
toRestart=($(comm -23 <(printf '%s\n' ${toRestart[@]}) \
<(printf '%s\n' ${toUpdate[@]})))
if ((${#toUpdate[@]} != 0)); then
echo "Updating containers:"
printf '%s\n' ${toUpdate[@]}
echo
updateContainers ${toUpdate[@]}
fi
fi
if ((${#toRestart[@]} != 0)); then
echo "Restarting containers:"
printf '%s\n' ${toRestart[@]}
echo
restartContainers ${toRestart[@]}
fi
fi
fi
if [[ $startShellEnv ]]; then
handleShell
fi
================================================
FILE: flake.nix
================================================
{
description = "Run declarative NixOS containers without full system rebuilds";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils }@inputs:
let
supportedSystems = [ "x86_64-linux" "i686-linux" "aarch64-linux" ];
eachSupportedSystem = flake-utils.lib.eachSystem supportedSystems;
pkg = pkgs: pkgs.callPackage ./. { pkgSrc = ./.; };
in
{
nixosModules.default = { pkgs, ... }: {
environment.systemPackages = [ (pkg pkgs) ];
boot.extraSystemdUnitPaths = [ "/etc/systemd-mutable/system" ];
};
overlays.default = final: prev: { extra-container = pkg final; };
lib = {
inherit supportedSystems eachSupportedSystem;
buildContainers = {
system
, config
, nixpkgs ? inputs.nixpkgs
, legacyInstallDirs ? false
, addRunner ? true
}: let
containers = self.lib.evalContainers { inherit system config nixpkgs legacyInstallDirs; };
etc = containers.config.system.build.etc;
withRunner = etc.overrideAttrs (old: {
name = "container";
buildCommand = old.buildCommand + "\n" + ''
install -D -m700 <(printf '${''
#!/usr/bin/env bash
if ! type -p extra-container >/dev/null; then
>&2 echo "Error: extra-container is not installed"
>&2 echo "Docs: https://github.com/erikarvstedt/extra-container?tab=readme-ov-file#install"
exit 1
fi
EXTRA_CONTAINER_ETC=%s exec extra-container "$@"
''}' "$out") $out/bin/container
'';
});
in
(if addRunner then withRunner else etc) // {
inherit (containers) config;
inherit (containers.config) containers;
};
evalContainers = {
system
, config
, nixpkgs ? inputs.nixpkgs
, legacyInstallDirs ? false
}: import ./eval-config.nix {
inherit
system
legacyInstallDirs;
nixosPath = nixpkgs + "/nixos";
systemConfig = config;
};
};
} // (eachSupportedSystem (system:
let
pkgs = import nixpkgs { inherit system; };
inherit (nixpkgs) lib;
in
rec {
packages.default = pkg pkgs;
# This dev shell allows running the `extra-container` command directly from the local
# source (./extra-container), for quick edit/test cycles.
# This only works when `nix develop` is started from the repo root directory.
devShells.default = let
# Extra PATH, as defined in ./default.nix
path = lib.makeBinPath (with pkgs; [
openssh
]);
in pkgs.stdenv.mkDerivation {
name = "shell";
shellHook = ''
PATH="${path}''${PATH:+:}$PATH"
# Enable calling the local source (./extra-container) with command `extra-container`
PATH="$(realpath .):$PATH"
# Use the pinned nixpkgs for building containers when running `extra-container`
export NIX_PATH="nixpkgs=${nixpkgs}''${NIX_PATH:+:}$NIX_PATH"
# See comment in ./extra-container for an explanation
export LOCALE_ARCHIVE=${pkgs.glibcLocales}/lib/locale/locale-archive
'';
};
packages = {
# Run a basic extra-container test in a NixOS VM
test = pkgs.testers.nixosTest {
name = "extra-container";
nodes.machine = { config, ... }: {
imports = [ self.nixosModules.default ];
# memorySize = 1024 needed for evaluating the container system
# memorySize = 1200 needed to avoid error during boot:
# 'agetty[817]: failed to open credentials directory'
virtualisation.memorySize = 1200;
nix.nixPath = [ "nixpkgs=${nixpkgs}" ];
system.stateVersion = config.system.nixos.release;
# Pre-build the container used by testScript
system.extraDependencies = let
basicContainer = import ./eval-config.nix {
nixosPath = "${nixpkgs}/nixos";
legacyInstallDirs = false;
inherit system;
systemConfig = {
containers.test.config.environment.etc.testFile.text = "testSuccess";
};
};
in [ basicContainer.config.system.build.etc ];
};
testScript = ''
config = '{ containers.test.config.environment.etc.testFile.text = "testSuccess"; }'
output = machine.succeed(
f"extra-container shell -E '{config}' --run c cat /etc/testFile"
)
if not "testSuccess" in output:
print(f"Test failed. Output:\n{output}")
'';
};
# Used by apps.vm
vm = (import "${nixpkgs}/nixos" {
inherit system;
configuration = { config, pkgs, lib, modulesPath, ... }: with lib; {
imports = [
self.nixosModules.default
"${modulesPath}/virtualisation/qemu-vm.nix"
];
virtualisation.graphics = false;
services.getty.autologinUser = "root";
nix.nixPath = [ "nixpkgs=${nixpkgs}" ];
system.stateVersion = config.system.nixos.release;
documentation.enable = false;
# Power off VM when the user exits the shell
systemd.services."serial-getty@".preStop = ''
echo o >/proc/sysrq-trigger
'';
# Pre-build a minimal container
system.extraDependencies = let
basicContainer = import ./eval-config.nix {
nixosPath = "${nixpkgs}/nixos";
legacyInstallDirs = false;
inherit system;
systemConfig = {};
};
in [ basicContainer.config.system.build.etc ];
};
}).config.system.build.vm;
runVM = pkgs.writers.writeBash "run-vm" ''
set -euo pipefail
export NIX_DISK_IMAGE=/tmp/extra-container-vm-img
rm -f $NIX_DISK_IMAGE
trap "rm -f $NIX_DISK_IMAGE" EXIT
export QEMU_OPTS="-smp $(nproc) -m 2000"
${packages.vm}/bin/run-*-vm
'';
debugTest = pkgs.writers.writeBash "run-debug-test" ''
set -euo pipefail
export TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
export QEMU_OPTS="-smp $(nproc) -m 2000"
${packages.test.driver}/bin/nixos-test-driver <(
echo "start_all(); import code; code.interact(local=globals())"
)
'';
updateReadme = pkgs.writers.writeBash "update-readme" ''
exec ${pkgs.ruby}/bin/ruby ./util/update-readme.rb
'';
};
apps = {
# Run a NixOS VM where extra-container is installed
vm = {
type = "app";
program = toString packages.runVM;
};
# Run a Python test driver shell inside the test VM
debugTest = {
type = "app";
program = toString packages.debugTest;
};
updateReadme = {
type = "app";
program = toString packages.updateReadme;
};
};
checks = { inherit (packages) test; };
}
));
}
================================================
FILE: run-tests-in-container.sh
================================================
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
scriptDir="$(dirname "$(readlink -f "$0")")"
PATH=$scriptDir:$PATH
cleanup() {
# clean immutable files inside the container
for f in /var/lib/*containers/test-extra-container/var/lib/*containers/*/var/empty; do
chattr -i -a "$f"
rm -rf "$f"
done
extra-container destroy test-extra-container || true
}
trap "cleanup" EXIT
trap "echo \"Error at $(realpath ${BASH_SOURCE[0]}):\$LINENO\"" ERR
cleanup
#―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
nixpkgs=$(nix-instantiate --eval -E '(toString <nixpkgs>)' | tr -d '"')
extra-container create -s <<EOF
{ config, pkgs, lib, ... }:
{
containers.test-extra-container = {
bindMounts."/extra-container".hostPath = "$scriptDir";
bindMounts."/nixpkgs".hostPath = "$nixpkgs";
config = { options, ... }: {
environment = {
systemPackages = [ pkgs.nixos-container ];
variables.NIX_PATH = lib.mkForce "nixpkgs=/nixpkgs";
};
boot = lib.optionalAttrs (options.boot ? extraSystemdUnitPaths) {
extraSystemdUnitPaths = [ "/etc/systemd-mutable/system" ];
};
};
};
}
EOF
echo "Running tests"
echo
nixos-container run test-extra-container -- '/extra-container/test.sh'
================================================
FILE: test.sh
================================================
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
scriptDir="$(dirname "$(readlink -f "$0")")"
PATH=$scriptDir:$PATH
cleanup() {
set +e
for container in $(extra-container list | grep ^test-); do
extra-container destroy $container
done
set -e
}
trap "cleanup" EXIT
trap "echo \"Error at $(realpath ${BASH_SOURCE[0]}):\$LINENO\"" ERR
testMatches() {
actual="$1"
expected="$2"
if [[ $actual != $expected ]]; then
echo
echo 'Pattern does not match'
echo 'Expected:'
echo "$expected"
echo
echo 'Actual:'
echo "$actual"
echo
return 1
fi
}
# This significantly reduces eval time. Not needed for NixOS ≥ 20.03
baseConfig='config = { documentation.nixos.enable = false; }'
cleanup
#―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
echo "Test attr arg and container starting "
output=$(extra-container create -A 'a b' --start <<EOF
{
"a b" = { config, pkgs, ... }: {
containers.test-1 = {
$baseConfig;
};
};
}
EOF
)
testMatches "$output" "*Installing*test-1*Starting*test-1*"
#―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
echo "Test starting and updating"
output=$(extra-container create -s <<EOF
{ config, pkgs, ... }:
{
containers.test-1 = {
$baseConfig // { environment.variables.foo = "a"; };
};
containers.test-2 = {
$baseConfig;
};
}
EOF
)
testMatches "$output" "*Starting*test-2*Updating*test-1*"
#―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
echo "Test unchanged"
output=$(extra-container create -s <<EOF
{ config, pkgs, ... }:
{
containers.test-1 = {
$baseConfig // { environment.variables.foo = "a"; };
};
}
EOF
)
testMatches "$output" "*test-1 (unchanged, skipped)*"
#―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
echo "Test updating and restarting"
output=$(extra-container create -u <<EOF
{ config, pkgs, ... }:
{
containers.test-1 = {
$baseConfig // { environment.variables.foo = "b"; };
};
containers.test-2 = {
privateNetwork = true;
$baseConfig;
};
}
EOF
)
testMatches "$output" "*Updating*test-1*Restarting*test-2*"
#―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
echo "Test list"
output=$(extra-container list | grep ^test- || true)
testMatches "$output" "test-1*test-2"
#―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
echo "Test destroy"
[[ $(echo /var/lib/*containers/test-*) ]]
cleanup
output=$(extra-container list | grep ^test- || true)
testMatches "$output" ""
[[ ! $(echo /var/lib/*containers/test-*) ]]
#―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
echo "Test shell run"
read -d '' src <<EOF || true
{ config, pkgs, ... }:
{
containers.test-1 = {
$baseConfig;
};
}
EOF
output=$(extra-container shell -E "$src" --run c uname -a)
testMatches "$output" "*Linux test*"
# Container should be destroyed after running
[[ ! -e /var/lib/*containers/test-1 ]]
#―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
echo "Test manual build"
storePath=$(extra-container build <<EOF
{ config, pkgs, ... }:
{
containers.test-1 = {
$baseConfig;
};
containers.test-2 = {
$baseConfig;
};
}
EOF
)
testMatches "$storePath" "/nix/store/*"
output=$(extra-container create -s $storePath)
testMatches "$output" "*Starting*test-1*test-2*"
#―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
echo "Test destroy from container definition"
extra-container destroy $storePath
[[ ! -e /var/lib/*containers/test-1 ]]
[[ ! -e /var/lib/*containers/test-2 ]]
# TODO: Add flake tests when flakes have stabilized
================================================
FILE: util/generate-sudoers.rb
================================================
# 1. Add $HOME/.nix-profile/bin to secure_path (Defaults)
# 2. Add NIX_PATH to env_keep (Defaults)
#
def make_sudoers(old_sudoers, extra_secure_paths)
secure_path_set = false
sudoers = old_sudoers.sub(/(Defaults\s+secure_path\s*=\s*"?)([^"\s]+)(.*)/) do |_|
secure_path_set = true
prefix, path, suffix = $1, $2, $3
paths = path.split(':')
extra_paths = extra_secure_paths.split(':')
new_path = (paths + extra_paths).uniq.join(':')
"#{prefix}#{new_path}#{suffix}"
end
env_keep_statement = "Defaults env_keep += NIX_PATH"
lines_to_add = []
lines_to_add << %(Defaults secure_path = "#{extra_secure_paths}") if !secure_path_set
lines_to_add << env_keep_statement if !old_sudoers.include?(env_keep_statement)
if !lines_to_add.empty?
sudoers << lines_to_add.join("\n") << "\n"
end
sudoers == old_sudoers ? nil : sudoers
end
if __FILE__ == $0
puts make_sudoers(STDIN.read, ARGV.first)
end
================================================
FILE: util/install.sh
================================================
#!/usr/bin/env bash
set -euo pipefail
# Install extra-container on a non-NixOS systemd-based system for use with sudo.
# Requires a multi-user nix installation, because extra-container runs nix-build as root.
# This script is idempotent.
[[ -e /run/booted-system/nixos-version ]] && isNixos=1 || isNixos=
[[ -e /run/systemd/system ]] && hasSystemd=1 || hasSystemd=
scriptDir=$(cd "${BASH_SOURCE[0]%/*}" && pwd)
if [[ $EUID == 0 ]]; then
echo "This script should NOT be run as root."
exit 1
fi
if [[ $isNixos ]]; then
echo "This install script is not needed on NixOS. See the README for installation instructions."
exit 1
fi
if [[ ! $hasSystemd ]]; then
echo "extra-container requires systemd."
exit 1
fi
if [[ ! -e /nix/var/nix/profiles/default ]]; then
echo "extra-container requires a multi-user nix installation."
exit 1
fi
## 1. Build extra-container
tmpDir=$(mktemp -d)
trap "rm -rf $tmpDir" EXIT
nix-build --out-link $tmpDir/extra-container -E "(import <nixpkgs> {}).callPackage ''$scriptDir/..'' {}"
## 2. Install to root user profile
sudo $(type -P nix-env) -i $tmpDir/extra-container
## 3. Edit /etc/sudoers to enable running extra-container via sudo
# See ./edit-sudoers.rb for more details
if ! type -P ruby > /dev/null; then
nix-build --out-link $tmpDir/ruby '<nixpkgs>' -A ruby > /dev/null
export PATH="$tmpDir/ruby/bin${PATH:+:}$PATH"
fi
extraSecurePaths=/root/.nix-profile/bin:/nix/var/nix/profiles/default/bin
newSudoersContent=$(sudo cat /etc/sudoers | ruby "$scriptDir"/generate-sudoers.rb "$extraSecurePaths")
if [[ $newSudoersContent ]]; then
echo "$newSudoersContent" | sudo EDITOR="tee" visudo >/dev/null
fi
================================================
FILE: util/update-readme.rb
================================================
#!/usr/bin/env ruby
def without_warnings
original_verbosity = $VERBOSE
$VERBOSE = nil
result = yield
$VERBOSE = original_verbosity
result
end
Dir.chdir(File.join(__dir__, '..'))
readme = File.read('README.md')
# hide `warning: Insecure world writable dir {extra-container-src-dir}`
usage = without_warnings { `extra-container`.sub(/\A.*?Usage:\s*/m, '') }
updated = readme.sub(/(?<=^## Usage\n```\n).*?(?=^```)/m, usage)
current = File.read('README.md')
if current != updated
File.write('README.md', updated)
puts "Updated README.md"
end
gitextract_dnnhq23t/
├── CHANGELOG.md
├── LICENSE
├── Makefile
├── README.md
├── default.nix
├── eval-config.nix
├── examples/
│ └── flake/
│ ├── flake.nix
│ └── usage.sh
├── extra-container
├── flake.nix
├── run-tests-in-container.sh
├── test.sh
└── util/
├── generate-sudoers.rb
├── install.sh
└── update-readme.rb
SYMBOL INDEX (2 symbols across 2 files) FILE: util/generate-sudoers.rb function make_sudoers (line 4) | def make_sudoers(old_sudoers, extra_secure_paths) FILE: util/update-readme.rb function without_warnings (line 3) | def without_warnings
Condensed preview — 15 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (80K chars).
[
{
"path": "CHANGELOG.md",
"chars": 2759,
"preview": "# 0.14 (2025-12-19)\n- Enhancements\n - Support NixOS unstable\n# 0.13 (2024-12-12)\n- Enhancements\n - Support NixOS optio"
},
{
"path": "LICENSE",
"chars": 1086,
"preview": "MIT License\n\nCopyright (c) 2018 The extra-container developers\n\nPermission is hereby granted, free of charge, to any per"
},
{
"path": "Makefile",
"chars": 193,
"preview": "all: test\n\ntest: runTests checkFlake\n\nrunTests:\n\tnix shell -c sudo ./run-tests-in-container.sh\n\ncheckFlake:\n\tnix flake c"
},
{
"path": "README.md",
"chars": 11939,
"preview": "# extra-container\n\nManage declarative NixOS containers like imperative containers, without system\nrebuilds.\n\nEach declar"
},
{
"path": "default.nix",
"chars": 1123,
"preview": "{ stdenv, lib, nixos-container, openssh, glibcLocales\n, pkgSrc ? lib.cleanSource ./.\n}:\n\nstdenv.mkDerivation rec {\n pna"
},
{
"path": "eval-config.nix",
"chars": 9353,
"preview": "{ nixosPath\n, systemConfig\n, legacyInstallDirs\n, system ? builtins.currentSystem\n}:\n\nlet\n # A minimal module set for ev"
},
{
"path": "examples/flake/flake.nix",
"chars": 1492,
"preview": "# See how this flake is used in ./usage.sh\n{\n inputs.extra-container.url = \"github:erikarvstedt/extra-container\";\n inp"
},
{
"path": "examples/flake/usage.sh",
"chars": 1498,
"preview": "# Usage via `nix run`\n\n#―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――\n# Container life"
},
{
"path": "extra-container",
"chars": 28723,
"preview": "#!/usr/bin/env bash\n\nset -eo pipefail\nshopt -s nullglob\n\n# This script uses systemctl, machinectl and nsenter from the e"
},
{
"path": "flake.nix",
"chars": 7749,
"preview": "{\n description = \"Run declarative NixOS containers without full system rebuilds\";\n\n inputs.nixpkgs.url = \"github:NixOS"
},
{
"path": "run-tests-in-container.sh",
"chars": 1294,
"preview": "#!/usr/bin/env bash\n\nset -euo pipefail\nshopt -s nullglob\n\nscriptDir=\"$(dirname \"$(readlink -f \"$0\")\")\"\nPATH=$scriptDir:$"
},
{
"path": "test.sh",
"chars": 3822,
"preview": "#!/usr/bin/env bash\n\nset -euo pipefail\nshopt -s nullglob\n\nscriptDir=\"$(dirname \"$(readlink -f \"$0\")\")\"\nPATH=$scriptDir:$"
},
{
"path": "util/generate-sudoers.rb",
"chars": 938,
"preview": "# 1. Add $HOME/.nix-profile/bin to secure_path (Defaults)\n# 2. Add NIX_PATH to env_keep (Defaults)\n#\ndef make_sudoers(ol"
},
{
"path": "util/install.sh",
"chars": 1683,
"preview": "#!/usr/bin/env bash\n\nset -euo pipefail\n\n# Install extra-container on a non-NixOS systemd-based system for use with sudo."
},
{
"path": "util/update-readme.rb",
"chars": 554,
"preview": "#!/usr/bin/env ruby\n\ndef without_warnings\n original_verbosity = $VERBOSE\n $VERBOSE = nil\n result = yield\n $VERBOSE ="
}
]
About this extraction
This page contains the full source code of the erikarvstedt/extra-container GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 15 files (72.5 KB), approximately 18.6k tokens, and a symbol index with 2 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.