encapsule 0.5.1 → 0.6
raw patch · 13 files changed
+465/−147 lines, 13 filesdep ~simple-cmd
Dependency ranges changed: simple-cmd
Files
- ChangeLog.md +10/−0
- README.md +93/−31
- data/config.toml +31/−0
- encapsule.cabal +13/−3
- example/config.toml +6/−26
- src/Backup.hs +2/−1
- src/Config.hs +30/−7
- src/Enter.hs +12/−10
- src/Main.hs +28/−15
- src/Run.hs +23/−13
- src/Script.hs +21/−26
- test/ConfigSpec.hs +160/−0
- test/Spec.hs +36/−15
ChangeLog.md view
@@ -1,5 +1,15 @@ # encapsule releases +## 0.6 (2026-10-10)+- make passwordless sudo setup opt-in with `--sudo` (replaces `--no-sudo`)+- add `--root` as shorthand for `--user root`+- `enter`: add `--root/--user`+- `commit`: use podman instead of buildah+- install default capabilities in datadir/config.toml+ (can be overriden by `XDG_CONFIG_DIRS` files and augmented by user config)+- add `--network-host` to share host networking for local servers+- add `pull` command (currently just a wrapper for `podman pull`)+ ## 0.5.1 (2026-09-15) - `enter` bugfix: -e=LANG must precede container - `enter`: allow an optional command like `run` (`enter TOOLBOX -- tmux`)
README.md view
@@ -14,20 +14,26 @@ Most encapsule subcommands act on an image. - If you wish to use an existing toolbox container as a starting point you can `commit` it to an "encapsule" container image. - Note your original toolbox container is left untouched: its system configuration and fs are just used as the base fs for the encapsule image (though its original bind mounts including $HOME will be not be included by default).-- Alternatively you can roll your own image or run a vanilla image like `fedora` (`fedora:latest`), `fedora-toolbox:44` or `ubuntu:latest`, etc.- - However toolbox images or containers are recommended because they include `sudo` and `runuser`, but as such it doesn't have to be a toolbox container.- - For example since the fedora base container does not include runuser it runs as `--user root` by default (since as of 0.5 util-linux is no longer-installed by default into encapsule containers: this may be addressed in future).+- Alternatively you can roll your own image or run a vanilla image like `fedora` (`fedora:latest`), `fedora-toolbox:45`, `ubuntu:latest`, etc.+ - Toolbox images or containers are generally recommended for development, but it doesn't have to be a toolbox container as such. -Encapsule images and containers are prefixed by `encapsule-`.-There is no need to use this prefix normally - it is implicit.+A typical invocation might look like: +```+$ encapsule someimage:tag --cap my-config --home ~/isolated --project projects/abc+```++(directories can be relative).++Encapsule images and containers are prefixed by `encapsule-`,+but there is no need to use this prefix normally - it is implicit.+ ## Usage `$ encapsule --version` ```-0.5.1+0.6 ``` `$ encapsule --help`@@ -47,6 +53,7 @@ Available commands: list List encapsule images and containers list-caps List available capabilities+ pull Pull a container image with podman rm Remove an encapsule container rmi Remove an encapsule image stop Stop an encapsule container@@ -62,20 +69,19 @@ ### `run` command -`run` starts a temporary encapsule container (removed on exit)-from a (toolbox) image or container.+`run` starts a temporary encapsule container (removed on exit) from an image. `$ encapsule run --help` ``` Usage: encapsule run IMAGE [-v|--volume HOST:CONTAINER[:opts]] [-e|--env KEY[=VALUE]] [--path DIR] [-i|--init CMD]- [--cap NAME] [--pull] [--user USER]+ [--cap NAME] [--pull] [--root | --user USER] [(-H|--home DIR[:opts]) [--backup-home]] [(-p|--project DIR[:opts]) [--backup-project]]- [-n|--name NAME] [--readonly] [--no-network] [--no-sudo]- [--no-skel] [--podman-opt OPTION] [--debug] [--dryrun]- [[--] CMD]+ [-n|--name NAME] [--readonly]+ [--no-network | --network-host] [--sudo] [--no-skel]+ [--podman-opt OPTION] [--debug] [--dryrun] [[--] CMD] Run a temporary encapsule container @@ -88,6 +94,7 @@ container --cap NAME Enable a capability from the config file --pull Pull newer container image+ --root Run as root (shorthand for --user root) --user USER Override container user [default: host/image user with host UID] -H,--home DIR[:opts] Mount a directory as a writable home (created if@@ -100,7 +107,8 @@ skip 'encapsule-' prefix) --readonly Make the encapsule container filesystem read-only --no-network Disable network access- --no-sudo Skip passwordless sudo setup+ --network-host Share the host network (including localhost ports)+ --sudo Set up passwordless sudo for the container user --no-skel Don't copy /etc/skel into an empty home --podman-opt OPTION Pass an option directly to podman --debug Show debug output@@ -108,6 +116,12 @@ -h,--help Show this help text ``` +Use `--network-host` with `run` (or `create`) to make servers on arbitrary+container localhost ports reachable on the same host localhost ports.+This also allows access to host-local services and shares the host's port+space. It cannot be combined with `--no-network`. Without either option,+Podman's default isolated container networking applies.+ ### `create` command `create` is similar but creates a reusable container for a project and/or temp home. @@ -119,10 +133,35 @@ $ encapsule enter my-toolbox -- tmux ``` +By default `enter` uses the user matching the host UID. Use `--root` (or+`--user root`) when administrative access is needed:++```bash+$ encapsule enter --root my-toolbox+```+ ### `commit` command `commit` saves a container as an encapsule image (`encapsule-CONTAINER` by default).-Use `-n/--name NAME` for a custom image name (`encapsule-NAME`, or `^NAME` to skip the prefix).+Use `-n/--name NAME` for a custom image name (becomes `encapsule-NAME`, or `^NAME` to skip the prefix). +### `list` command+Lists the encapsule images and containers:++```+$ ncpsl list+localhost/encapsule-harness:latest 6.15 GB 12 days ago++encapsule-harness-encapsule Up 31 hours+encapsule-fedora Up 29 minutes+encapsule-fedora-toolbox-45 Up 5 seconds+```++### `backup` command+This is really independent of encapsule.+It provides a simple way to create a backup tarball of a (git) project or dir.+There are also runtime `--backup-project` and `--backup-home` options.++ ## Examples ```bash@@ -175,7 +214,8 @@ ## Capabilities Capabilities define reusable groups of volumes, environment variables,-PATH entries, and init commands in `~/.config/encapsule/config.toml`:+PATH entries, and init commands in `/usr/share/encapsule/config.toml`+and `~/.config/encapsule/config.toml`: ```toml [capabilities.ssh]@@ -193,6 +233,10 @@ path = ["~/.cargo/bin"] ``` +There is an example user config file: `example/config.toml`.++Use `encapsule list-caps` to list all the available defined capabilities.+ Each capability can define: - `volumes` : list of bind mount specs@@ -205,26 +249,43 @@ If the host and container paths are the same, you can use the shorthand `PATH[:opts]` instead of `PATH:PATH[:opts]`. +The default defined capabilities live in the bundled `data/config.toml` file.+These capabilities can be overridden by XDG system config files+(under `XDG_CONFIG_DIRS`): by default `/etc/xdg/encapsule/config.toml`.+User settings (under `XDG_CONFIG_HOME`) take precedence over these settings;+earlier directories in `XDG_CONFIG_DIRS` take precedence over later ones.+Nested tables are merged, so overriding one field of a capability preserves+its other system defaults. Arrays are replaced rather than appended;+use an empty array to clear a default.++Note that an existing XDG system config, even an empty file,+replaces the bundled default definitions entirely,+whereas the user config will augment or modify them.++To be quite clear, no capabilities are used by default:+they need to be explicitly enabled on the commandline.+The definitions just make them available to use.+ ## How it works -0. Commits the named toolbox container to an encapsule image using `buildah commit`.+0. Optionally commits the named toolbox container to an encapsule image (using `podman commit`). 1. Runs `podman run` with `--userns=keep-id` so you are your own user, not root-2. Drops from root with `runuser` if present, otherwise `sudo -u`- (`enter` uses `podman exec --user`)-3. Sets up passwordless `sudo` inside the encapsule container (unless `--no-sudo`)+2. Drops from root with `runuser` if present, otherwise `sudo -u`. Explicit+ `--user root`/`--root` runs directly as root (`enter` uses+ `podman exec --user`)+3. Does not grant privilege escalation by default. `--sudo` opts into a+ passwordless sudoers entry when the image contains `sudo` 4. Bind mounts get SELinux `:z` (shared) labels automatically, so multiple containers can safely access the same directories 5. When `-p/--project DIR` is used (and `--name` isn't), the container name includes the project directory's name (e.g. `encapsule-mytoolbox-myproject`), so you can run the same toolbox against different projects at the same time- in separate encapsule containers. Though for different project paths with- the same directory name the container name will not be differentiated.+ in separate encapsule containers. For invocations with the same name,+ the container name gets differentiated by pid. ## Installation -A copr repo is available for Fedora and EPEL 10:--<https://copr.fedorainfracloud.org/coprs/petersen/encapsule/>+Encapsule is packaged in Fedora: <https://src.fedoraproject.org/rpms/encapsule> ## Building from source @@ -251,8 +312,8 @@ (`--dryrun` against local images, plus an optional live `run`). It needs podman and skips missing images. -Default images are `ubuntu:latest` and `fedora:latest`.-Override with `ENCAPSULE_TEST_UBUNTU` and `ENCAPSULE_TEST_FEDORA`.+Default images are `fedora:latest` and `ubuntu:latest`.+Override with `ENCAPSULE_TEST_FEDORA` and `ENCAPSULE_TEST_UBUNTU`. Live tests need a TTY, or set `ENCAPSULE_LIVE=1` to try without one. `ENCAPSULE` selects a different encapsule binary. @@ -271,9 +332,10 @@ ## Runtime Requirements -- [podman](https://podman.io/) and [buildah](https://buildah.io/)-- An existing (toolbox) container (created with `toolbox create`) or an image.-- Alternatively other non-toolbox container/images can also work.+- [podman](https://podman.io/)+- An existing (toolbox) image (optionally created from a container with `toolbox create`).+ - Use `encapsule pull` (or `podman pull`) to get an image first.+- Encapsule has only been tested on Linux. ## Related projects @@ -283,7 +345,7 @@ Another somewhat related project is [podenv](https://github.com/podenv/podenv), which "provides a declarative interface to manage containerized applications." -For stronger sandboxing and isolation, specially network, consider using [OpenShell](https://github.com/NVIDIA/OpenShell/). At some point this project might move to wrapping or supporting openshell possibly.+For stronger sandboxing and isolation, specially network, consider using [OpenShell](https://github.com/NVIDIA/OpenShell/). At some point this project might support openshell perhaps. There is also [litterbox](https://github.com/Gerharddc/litterbox) which has quite a lot of features and though somewhat opinionated, for example like openshell also supports landlock confinement.
+ data/config.toml view
@@ -0,0 +1,31 @@+# table fields can be overriden by+# - /etc/xdg/encapsule/config.toml (XDG_CONFIG_DIRS)+# - ~/.config/encapsule/config.toml (XDG_CONFIG_HOME)++[capabilities.ssh]+volumes = ["~/.ssh:ro"]++[capabilities.git]+volumes = ["~/.gitconfig:ro"]++# gtk4 needs libglvnd-gles+[capabilities.wayland]+env = ["WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"]+volumes = ["$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY"]+security_opts = ["label=disable"]++[capabilities.dbus]+volumes = ["/run/dbus/system_bus_socket"]++[capabilities.dconf]+volumes = ["$XDG_RUNTIME_DIR/dconf"]++[capabilities.machine-id]+volumes = ["/etc/machine-id:ro"]++[capabilities.rust]+path = ["~/.cargo/bin"]++[capabilities.ssh-agent]+env = ["XDG_RUNTIME_DIR"]+volumes = ["$XDG_RUNTIME_DIR/ssh-agent.sock"]
encapsule.cabal view
@@ -1,12 +1,12 @@ cabal-version: 2.2 name: encapsule-version: 0.5.1+version: 0.6 synopsis: Run isolated toolbox containers with podman description: This tool (originally based on the toolbox-constrained project) allows running isolated toolbox containers with podman. Mounting of home and host integration are not enabled by default,- but one can choose options to do so including capabilities+ but one can use options to do so including capabilities specified in a toml configuration file. license: Apache-2.0 license-file: LICENSE@@ -17,6 +17,7 @@ homepage: https://github.com/juhp/encapsule bug-reports: https://github.com/juhp/encapsule/issues build-type: Simple+data-files: data/config.toml extra-doc-files: README.md ChangeLog.md example/config.toml@@ -84,13 +85,22 @@ type: exitcode-stdio-1.0 main-is: Spec.hs other-modules: EncapsuleTest- hs-source-dirs: test+ ConfigSpec+ Config+ Paths_encapsule+ autogen-modules: Paths_encapsule+ hs-source-dirs: test src build-depends: base < 5+ , containers , directory , filepath , hspec , process+ , simple-cmd+ , text+ , toml-reader , unix+ , xdg-basedir build-tool-depends: encapsule:encapsule default-language: Haskell2010 ghc-options: -Wall -threaded
example/config.toml view
@@ -1,30 +1,10 @@-[capabilities.ssh]-volumes = ["~/.ssh:~/.ssh:ro"]--[capabilities.git]-volumes = ["~/.gitconfig:ro"]--# gtk4 needs libglvnd-gles-[capabilities.wayland]-env = ["WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"]-volumes = ["$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY"]-security_opts = ["label=disable"]--[capabilities.dbus]-volumes = ["/run/dbus/system_bus_socket"]--[capabilities.dconf]-volumes = ["$XDG_RUNTIME_DIR/dconf"]--[capabilities.machine-id]-volumes = ["/etc/machine-id:ro"]--[capabilities.rust]-path = ["~/.cargo/bin"]+# example ~/.config/encapsule/config.toml [capabilities.isolation] volumes = ["~/isolation:~:rw"] -[capabilities.ssh-agent]-env = ["XDG_RUNTIME_DIR"]-volumes = ["$XDG_RUNTIME_DIR/ssh-agent.sock"]+[capabilities.agents]+volumes = ["~/AGENTS.md:ro"]++[capabilities.unconfined]+security_opts = ["label=disable"]
src/Backup.hs view
@@ -22,6 +22,7 @@ import Expand import ShellQuote +-- FIXME add --output-dir backupCmd :: Bool -> Bool -> Maybe FilePath -> FilePath -> IO () backupCmd dryrun yes moutput dir = do homedir <- getHomeDirectory >>= canonicalizePath@@ -55,7 +56,7 @@ else do checkSize yes Nothing src (if dryrun then cmdN else cmd_) "tar" $ map shellQuote args ++ [base]- putStrLn $ "Wrote" +-+ tarball+ putStrLn $ (if dryrun then "Would write" else "Wrote") +-+ tarball -- Prompt when backing up more than this many bytes. largeBackupBytes :: Integer
src/Config.hs view
@@ -9,29 +9,52 @@ ) where -import Data.List (intercalate)+import Control.Monad (filterM)+import Data.List (delete, intercalate) import qualified Data.Map.Strict as Map import Data.Maybe (mapMaybe) import qualified Data.Text as T import SimpleCmd (error', (+-+)) import System.Directory (doesFileExist)-import System.Environment.XDG.BaseDir (getUserConfigFile)+import System.Environment.XDG.BaseDir (getAllConfigFiles, getUserConfigFile) import TOML (Value(..), Table, renderTOMLError, decodeFile) +import Paths_encapsule (getDataFileName)+ progname :: String progname = "encapsule" loadConfig :: IO (Maybe Table) loadConfig = do- path <- getUserConfigFile progname "config.toml"- exists <- doesFileExist path- if not exists+ userPath <- getUserConfigFile progname "config.toml"+ configPaths <- getAllConfigFiles progname "config.toml" >>= filterM doesFileExist+ paths <- if null $ delete userPath configPaths+ then do+ bundledPath <- getDataFileName "data/config.toml"+ exists <- doesFileExist bundledPath+ return $ configPaths ++ [bundledPath | exists]+ else return configPaths+ if null paths then return Nothing else do+ tables <- mapM loadTable paths+ return $ Just $ foldr mergeTables Map.empty tables+ where+ loadTable path = do result <- decodeFile path case result of- Left e -> error' $ "config parse error:" +-+ T.unpack (renderTOMLError e)- Right table -> return (Just table)+ Left e -> error' $ "config parse error in" +-+ path ++ ":" +-++ T.unpack (renderTOMLError e)+ Right table -> return table++-- XDG lists the user file first, followed by system files in priority order.+-- Merge nested tables, but replace other values (including arrays).+mergeTables :: Table -> Table -> Table+mergeTables = Map.unionWith mergeValue+ where+ mergeValue (Table preferred) (Table fallback) =+ Table $ mergeTables preferred fallback+ mergeValue preferred _ = preferred getCapabilities :: Maybe Table -> Table getCapabilities Nothing = Map.empty
src/Enter.hs view
@@ -10,7 +10,7 @@ where import Control.Monad (unless, when)-import Data.Maybe (fromMaybe, isNothing)+import Data.Maybe (fromMaybe, isJust, isNothing) import SimpleCmd (cmd, cmd_, cmdFull) import System.Directory (canonicalizePath, getHomeDirectory) import System.Exit (exitWith)@@ -20,13 +20,13 @@ import ShellQuote -enterContainer :: Bool -> Bool -> Bool -> String -> [String] -> IO ()-enterContainer dryrun debug running container command = do+enterContainer :: Bool -> Bool -> Bool -> Maybe String -> String -> [String] -> IO ()+enterContainer dryrun debug running muser container command = do hostHome <- getHomeDirectory >>= canonicalizePath unless running $ do putStr "start " cmd_ "podman" ["start", container]- (username, mPasswdHome) <- lookupContainerUser container+ (username, mPasswdHome) <- lookupContainerUser container muser wd <- containerWorkdir container let userCmd = if null command then ["bash"] else command homeDir = fromMaybe hostHome mPasswdHome@@ -38,7 +38,9 @@ "/" -> homeDir d -> d homeEnv =- if isNothing mPasswdHome then ["env", "HOME=" ++ homeDir] else []+ if isJust muser || isNothing mPasswdHome+ then ["env", "HOME=" ++ homeDir]+ else [] execArgs = ["exec", "-it", "--user", username] ++ langEnvArgs ++ ["--workdir", workdir, container]@@ -72,17 +74,17 @@ | isAbsolute h = Just h | otherwise = Nothing -lookupContainerUser :: String -> IO (String, Maybe FilePath)-lookupContainerUser container = do- hostName <- getEffectiveUserName+lookupContainerUser :: String -> Maybe String -> IO (String, Maybe FilePath)+lookupContainerUser container muser = do+ hostUserName <- getEffectiveUserName uid <- getEffectiveUserID let uidStr = show (fromIntegral uid :: Integer)- sh = passwdEntryForUidSh uidStr+ sh = maybe (passwdEntryForUidSh uidStr) passwdEntryForNameSh muser (_, out, _) <- cmdFull "podman" ["exec", container, "/bin/sh", "-c", sh] "" case lines out of (n:h:_) | not (null n) -> return (n, usablePasswdHome h) (n:_) | not (null n) -> return (n, Nothing)- _ -> return (hostName, Nothing)+ _ -> return (fromMaybe hostUserName muser, Nothing) containerWorkdir :: String -> IO String containerWorkdir container =
src/Main.hs view
@@ -28,6 +28,8 @@ pure listCmd , Subcommand "list-caps" "List available capabilities" $ pure listCapsCmd+ , Subcommand "pull" "Pull a container image with podman" $+ pullCmd <$> strArg "IMAGE" , Subcommand "rm" "Remove an encapsule container" $ removeCmd <$> strArg "TOOLBOX"@@ -60,6 +62,7 @@ <*> pure True <*> optional (strArg "TOOLBOX") <*> optional projectNameOpt+ <*> optional userNameOpt <*> many (strArg "[--] CMD") , Subcommand "run" "Run a temporary encapsule container" $ runCmd <$> runOpts False True@@ -74,7 +77,7 @@ projectNameOpt = Project <$> projectOpt "Project name or path" <|> Name <$> nameOpt - backupDirOpt s l m h =+ dirOptBackup s l m h = let pair fs sn = (fs,sn) in pair <$> strOptionWith s l m h@@ -82,6 +85,10 @@ debugOpt = switchLongWith "debug" "Show debug output" + userNameOpt =+ flagLongWith' "root" "root" "Run as root (shorthand for --user root)" <|>+ strOptionLongWith "user" "USER" "Override container user [default: host/image user with host UID]"+ runOpts keep unique = Run.RunOpts <$> strArg "IMAGE"@@ -91,14 +98,16 @@ <*> many (strOptionWith 'i' "init" "CMD" "A bash snippet run when creating the encapsule container") <*> many (strOptionLongWith "cap" "NAME" "Enable a capability from the config file") <*> switchLongWith "pull" "Pull newer container image"- <*> optional (strOptionLongWith "user" "USER" "Override container user [default: host/image user with host UID]")- <*> optional (backupDirOpt 'H' "home" "DIR[:opts]" "Mount a directory as a writable home (created if missing; use DIR:O to overlay)")- <*> optional (backupDirOpt 'p' "project" "DIR[:opts]" "Mount a (project) directory as workdir (use DIR:O to overlay)")+ <*> optional userNameOpt+ <*> optional (dirOptBackup 'H' "home" "DIR[:opts]" "Mount a directory as a writable home (created if missing; use DIR:O to overlay)")+ <*> optional (dirOptBackup 'p' "project" "DIR[:opts]" "Mount a (project) directory as workdir (use DIR:O to overlay)") <*> optional nameOpt <*> pure keep <*> switchLongWith "readonly" "Make the encapsule container filesystem read-only"- <*> switchLongWith "no-network" "Disable network access"- <*> switchLongWith "no-sudo" "Skip passwordless sudo setup"+ <*> optional+ (flagLongWith' NetNone "no-network" "Disable network access" <|>+ flagLongWith' NetHost "network-host" "Share the host network (including localhost ports)")+ <*> switchLongWith "sudo" "Set up passwordless sudo for the container user" <*> switchLongWith "no-skel" "Don't copy /etc/skel into an empty home" <*> pure unique <*> many (strOptionLongWith "podman-opt" "OPTION" "Pass an option directly to podman")@@ -157,8 +166,9 @@ else warning $ "container" +-+ containerName +-+ "not found" enterCmd :: Bool -> Bool -> Bool -> Maybe String -> Maybe ProjectName+ -> Maybe String -> [String] -> IO ()-enterCmd dryrun debug running mbase mprojectname command = do+enterCmd dryrun debug running mbase mprojectname muser command = do regexp <- case mprojectname of Nothing -> return $ progname +=+ fromMaybe "" mbase@@ -175,16 +185,21 @@ [] -> if running then do- enterCmd dryrun debug False mbase mprojectname command+ enterCmd dryrun debug False mbase mprojectname muser command else error' "encapsule container not found" [c] -> do unless running $ warning "no running encapsule container found"- enterContainer dryrun debug True c command+ enterContainer dryrun debug True muser c command _ -> error' $ "multiple" +-+ (if running then "running" else "") +-+ "containers match:\n" ++ unlines ps -- image management +pullCmd :: String -> IO ()+pullCmd image = do+ needPodman+ cmd_ "podman" ["pull", image]+ commitCmd :: Bool -> Maybe String -> String -> IO () commitCmd dryrun mname toolbox = do needPodman@@ -197,9 +212,7 @@ imageExists <- cmdBool "podman" ["image", "exists", image] unless imageExists $ putStrLn $ "creating new image:" +-+ image- let buildah_args = ["commit", "--disable-compression", toolbox, image]- if dryrun- then cmdN "buildah" buildah_args- else do- putStr "writing image "- cmd_ "buildah" buildah_args+ let commit_args = ["commit", toolbox, image]+ unless dryrun $+ putStr "writing image "+ (if dryrun then cmdN else cmd_) "podman" commit_args
src/Run.hs view
@@ -4,6 +4,7 @@ module Run ( ProjectName(..),+ NetworkMode(..), RunOpts(..), runCmd, (+=+),@@ -46,6 +47,8 @@ data ProjectName = Project FilePath | Name String +data NetworkMode = NetNone | NetHost+ data RunOpts = RunOpts { toolbox :: String , vols :: [String]@@ -60,8 +63,8 @@ , mname :: Maybe String , keep :: Bool , readonly :: Bool- , nonetwork :: Bool- , nosudo :: Bool+ , networkMode :: Maybe NetworkMode+ , sudoEnable :: Bool , noskel :: Bool , unique :: Bool , podmanopts :: [String]@@ -122,15 +125,15 @@ , isNothing muser , not keep , not readonly- , not nonetwork- , not nosudo+ , isNothing networkMode+ , not sudoEnable , not noskel , null podmanopts ] unless noopts $ error' "cannot give options for an existing container!" warning "Entering existing container"- enterContainer dryrun debugging True container command+ enterContainer dryrun debugging True Nothing container command else do when backupHome $ whenJust mhomeDir $ backupCmd dryrun False Nothing@@ -184,16 +187,20 @@ Nothing -> maybe getEffectiveUserName return mImageUser debug $ "user:" +-+ username - let switch = chooseSwitchUser haveRunuser haveSudo+ let mswitch =+ if username == "root"+ then Nothing+ else chooseSwitchUser haveRunuser haveSudo startAsRoot =- canSwitchUser switch+ username == "root"+ || isJust mswitch || isNothing mhome && isNothing muser && isNothing mImageUser- stayAsRoot = startAsRoot && not (canSwitchUser switch)+ stayAsRoot = startAsRoot && isNothing mswitch (containerHome, overrideHome) = if stayAsRoot then ("/root", False) else (fromMaybe hostHome mPasswdHome, isNothing mPasswdHome)- debug $ "switch:" +-+ switchLabel switch+ debug $ "switch:" +-+ maybe "none" switchLabel mswitch debug $ "container home:" +-+ containerHome homeVol <-@@ -250,14 +257,14 @@ -- mkdir+chown when not bind-mounting --home. A passwd home may -- already exist but not be writable (committed toolbox image). setupArgs =- Setup nosudo noskel (TL.pack username) progname (isNothing mhome) (TL.pack containerHome) mprojectDir+ Setup sudoEnable noskel (TL.pack username) progname (isNothing mhome) (TL.pack containerHome) mprojectDir setupParts =- let setup = setupScript debugging switch haveSudo setupArgs+ let setup = setupScript debugging mswitch haveSudo setupArgs in [setup | not (null setup)] ++ [mkInitSetup allinits | not (null allinits)] finalCmd =- case switchUserArgs switch username of+ case maybe [] (switchUserArgs username) mswitch of [] -> "exec" +-+ userCmd args -> "exec" +-+ unwords args +-+ userCmd execScript =@@ -322,7 +329,10 @@ Nothing -> ["--tmpfs", containerHome] Just _ -> [] else [])- ++ (if nonetwork then ["--net", "none"] else [])+ ++ (case networkMode of+ Nothing -> []+ Just NetNone -> ["--net", "none"]+ Just NetHost -> ["--net", "host"]) ++ concatMap (\s -> ["--security-opt", s]) securityOpts ++ concatMap (\m -> ["-v", m]) (tzMounts ++ mounts) ++ concatMap (\e -> ["-e", e]) envVars
src/Script.hs view
@@ -8,7 +8,6 @@ module Script ( SwitchUser(..), chooseSwitchUser,- canSwitchUser, switchUserArgs, switchLabel, setupScript,@@ -18,37 +17,31 @@ import Control.Monad (unless, when) import Control.Monad.Shell-import Data.Maybe (isNothing)+import Data.Maybe (isJust, isNothing) import qualified Data.Text.Lazy as T import System.FilePath ((</>)) import System.Posix.IO default (T.Text) -data SwitchUser = Runuser | Sudo | None--chooseSwitchUser :: Bool -> Bool -> SwitchUser-chooseSwitchUser True _ = Runuser-chooseSwitchUser False True = Sudo-chooseSwitchUser _ _ = None+data SwitchUser = Runuser | Sudo -canSwitchUser :: SwitchUser -> Bool-canSwitchUser None = False-canSwitchUser _ = True+chooseSwitchUser :: Bool -> Bool -> Maybe SwitchUser+chooseSwitchUser True _ = Just Runuser+chooseSwitchUser False True = Just Sudo+chooseSwitchUser _ _ = Nothing -- argv prefix; empty for None-switchUserArgs :: SwitchUser -> String -> [String]-switchUserArgs Runuser u = ["runuser", "-u", u, "--"]-switchUserArgs Sudo u = ["sudo", "-n", "--preserve-env", "-u", u, "--"]-switchUserArgs None _ = []+switchUserArgs :: String -> SwitchUser -> [String]+switchUserArgs u Runuser = ["runuser", "-u", u, "--"]+switchUserArgs u Sudo = ["sudo", "-n", "--preserve-env", "-u", u, "--"] switchLabel :: SwitchUser -> String switchLabel Runuser = "runuser" switchLabel Sudo = "sudo"-switchLabel None = "none" data Setup = Setup- { nosudo :: Bool+ { sudoEnable :: Bool , noskel :: Bool , username :: T.Text , program :: String@@ -57,8 +50,8 @@ , mprojectDir :: Maybe FilePath } -setupScript :: Bool -> SwitchUser -> Bool -> Setup -> String-setupScript dbg switch haveSudo (Setup {..}) =+setupScript :: Bool -> Maybe SwitchUser -> Bool -> Setup -> String+setupScript dbg mswitch haveSudo (Setup {..}) = T.unpack . T.replace "\t" " " . linearScript $ sudoSetup >> homeSetup where@@ -69,24 +62,26 @@ sudoSetup = when haveSudo $- unless nosudo $- let sudoers = "/etc/sudoers.d" in- whenCmd (test $ TDirExists sudoers) $ do- runHide "echo" [username, "ALL=(ALL) NOPASSWD:ALL"] `redir` (sudoers </> program)- runHide "chmod" ["440", T.pack sudoers]+ when sudoEnable $+ -- FIXME handle no sudoers.d?+ let sudoersd = "/etc/sudoers.d" in+ whenCmd (test $ TDirExists sudoersd) $ do+ let sudoersfile = sudoersd </> program+ runHide "echo" [username, "ALL=(ALL) NOPASSWD:ALL"] `redir` sudoersfile+ runHide "chmod" ["440", T.pack sudoersfile] homeSetup = do when createhome $ do runHide "mkdir" ["-p", homedir] runHide "chown" [username, homedir] unless noskel $- when (canSwitchUser switch) $+ when (isJust mswitch) $ whenCmd (test (TDirExists homedir) -&&- test (TDirExists (T.pack "/etc/skel"))) $ let cpArgs = ["-a", "--update=none", "/etc/skel/.", homedir <> "/"]- in case map T.pack $ switchUserArgs switch (T.unpack username) of+ in case map T.pack $ maybe [] (switchUserArgs (T.unpack username)) mswitch of prog:args -> runHide prog $ args ++ "cp" : cpArgs [] -> return () when (isNothing mprojectDir && createhome) $
+ test/ConfigSpec.hs view
@@ -0,0 +1,160 @@+-- SPDX-License-Identifier: Apache-2.0++module ConfigSpec (spec) where++import Control.Exception (bracket)+import qualified Data.Map.Strict as Map+import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive)+import System.Environment (lookupEnv, setEnv, unsetEnv)+import System.FilePath ((</>), takeDirectory)+import System.Posix.Temp (mkdtemp)+import Test.Hspec++import Config (getCapabilities, loadConfig, resolveCapabilities)+import EncapsuleTest (encapsule)++spec :: Spec+spec = describe "config" $ do+ it "returns Nothing when no config files exist, including bundled defaults" $+ withConfigDirs $ \_ _ _ ->+ loadConfig `shouldReturn` Nothing++ it "loads bundled defaults when no user or system config exists" $+ withConfigDirs $ \user _ _ -> do+ writeBundledConfig user "[capabilities.bundled]\nenv = ['BUNDLED']\n"+ resolve ["bundled"] `shouldReturn` ([], ["BUNDLED"], [], [], [])+ out <- encapsule ["list-caps"]+ out `shouldBe` "Available capabilities:\n bundled\n"++ it "merges user overrides over bundled defaults without a system config" $+ withConfigDirs $ \user _ _ -> do+ writeBundledConfig user $ unlines+ [ "[capabilities.shared]"+ , "env = ['BUNDLED']"+ , "path = ['/bundled/bin']"+ , "[capabilities.bundled]"+ , "volumes = ['bundled-volume']"+ ]+ writeConfig user "[capabilities.shared]\nenv = ['USER']\n"+ resolve ["shared", "bundled"] `shouldReturn`+ (["bundled-volume"], ["USER"], ["/bundled/bin"], [], [])++ it "ignores bundled defaults when a system config exists" $+ withConfigDirs $ \user _ system -> do+ writeBundledConfig user "[capabilities.bundled]\nenv = ['BUNDLED']\n"+ writeConfig system "[capabilities.system]\nenv = ['SYSTEM']\n"+ out <- encapsule ["list-caps"]+ out `shouldBe` "Available capabilities:\n system\n"++ it "lets an empty system config suppress bundled defaults" $+ withConfigDirs $ \user system _ -> do+ writeBundledConfig user "[capabilities.bundled]\nenv = ['BUNDLED']\n"+ writeConfig system ""+ (getCapabilities <$> loadConfig) `shouldReturn` Map.empty++ it "reports malformed bundled defaults when no system config exists" $+ withConfigDirs $ \user _ _ -> do+ writeBundledConfig user "[broken\n"+ out <- encapsule ["list-caps"]+ out `shouldContain` "config parse error in"+ out `shouldContain` (takeDirectory user </> "bundled" </> "data" </> "config.toml")++ it "ignores malformed bundled defaults when a system config exists" $+ withConfigDirs $ \user system _ -> do+ writeBundledConfig user "[broken\n"+ writeConfig system "[capabilities.system]\nenv = ['SYSTEM']\n"+ resolve ["system"] `shouldReturn` ([], ["SYSTEM"], [], [], [])++ it "loads a user config without a system config" $+ withConfigDirs $ \user _ _ -> do+ writeConfig user "[capabilities.user]\nenv = ['USER_ONLY']\n"+ resolve ["user"] `shouldReturn` ([], ["USER_ONLY"], [], [], [])++ it "loads a system config without a user config" $+ withConfigDirs $ \_ system _ -> do+ writeConfig system "[capabilities.system]\nenv = ['SYSTEM_ONLY']\n"+ resolve ["system"] `shouldReturn` ([], ["SYSTEM_ONLY"], [], [], [])++ it "merges capability fields and replaces arrays and scalar values" $+ withConfigDirs $ \user system _ -> do+ writeConfig system $ unlines+ [ "[capabilities.shared]"+ , "volumes = ['system-volume']"+ , "env = ['SYSTEM_ENV']"+ , "path = ['/system/bin']"+ , "init = 'system-init'"+ , "security_opts = ['label=disable']"+ , "[capabilities.system]"+ , "env = ['SYSTEM_ONLY']"+ ]+ writeConfig user $ unlines+ [ "[capabilities.shared]"+ , "volumes = []"+ , "env = ['USER_ENV']"+ , "init = 'user-init'"+ , "[capabilities.user]"+ , "env = ['USER_ONLY']"+ ]+ resolve ["shared"] `shouldReturn`+ ([], ["USER_ENV"], ["/system/bin"], ["user-init"], ["label=disable"])+ resolve ["system", "user"] `shouldReturn`+ ([], ["SYSTEM_ONLY", "USER_ONLY"], [], [], [])++ it "respects system directory order and user precedence" $+ withConfigDirs $ \user first second -> do+ writeConfig second "[capabilities.shared]\nenv = ['SECOND']\npath = ['/second/bin']\n"+ writeConfig first "[capabilities.shared]\nenv = ['FIRST']\n"+ resolve ["shared"] `shouldReturn` ([], ["FIRST"], ["/second/bin"], [], [])+ writeConfig user "[capabilities.shared]\nenv = ['USER']\n"+ resolve ["shared"] `shouldReturn` ([], ["USER"], ["/second/bin"], [], [])++ it "lets a user value replace a system table" $+ withConfigDirs $ \user system _ -> do+ writeConfig system "[capabilities.shared]\nenv = ['SYSTEM']\n"+ writeConfig user "capabilities = []\n"+ (getCapabilities <$> loadConfig) `shouldReturn` Map.empty++ it "reports the path of a malformed system config even with a user config" $+ withConfigDirs $ \user system _ -> do+ writeConfig user "[capabilities.user]\nenv = ['USER']\n"+ writeConfig system "[broken\n"+ out <- encapsule ["list-caps"]+ out `shouldContain` "config parse error in"+ out `shouldContain` (system </> "encapsule" </> "config.toml")++resolve :: [String] -> IO ([String], [String], [String], [String], [String])+resolve names = do+ caps <- getCapabilities <$> loadConfig+ resolveCapabilities caps names++writeConfig :: FilePath -> String -> IO ()+writeConfig dir contents = do+ let appDir = dir </> "encapsule"+ createDirectoryIfMissing True appDir+ writeFile (appDir </> "config.toml") contents++writeBundledConfig :: FilePath -> String -> IO ()+writeBundledConfig user contents = do+ let dataDir = takeDirectory user </> "bundled" </> "data"+ createDirectoryIfMissing True dataDir+ writeFile (dataDir </> "config.toml") contents++withConfigDirs :: (FilePath -> FilePath -> FilePath -> IO a) -> IO a+withConfigDirs action =+ bracket (mkdtemp "/tmp/encapsule-config-test-XXXXXX") removeDirectoryRecursive $ \tmp -> do+ let user = tmp </> "user"+ first = tmp </> "system-first"+ second = tmp </> "system-second"+ withEnv "XDG_CONFIG_HOME" user $+ withEnv "XDG_CONFIG_DIRS" (first ++ ":" ++ second) $+ withEnv "encapsule_datadir" (tmp </> "bundled") $+ action user first second++withEnv :: String -> String -> IO a -> IO a+withEnv key value action =+ bracket (lookupEnv key) restore $ \_ -> do+ setEnv key value+ action+ where+ restore Nothing = unsetEnv key+ restore (Just old) = setEnv key old
test/Spec.hs view
@@ -16,13 +16,20 @@ import Test.Hspec import EncapsuleTest+import qualified ConfigSpec main :: IO () main = hspec spec spec :: Spec spec = do+ ConfigSpec.spec describe "dryrun" $ do+ it "shares host networking with --network-host" $+ withGenericImage $ \img -> do+ out <- dryrun ["--network-host", img]+ out `shouldContain` "--net host"+ it "podman run has keep-id, TERM & LANG" $ withGenericImage $ \img -> do out <- dryrun [img]@@ -53,15 +60,24 @@ out `shouldContain` "--name encapsule-" out `shouldContain` "-proj" - it "uses runuser or sudo to switch with --user root" $+ it "runs directly as root with --user root" $ withGenericImage $ \img -> do- out <- dryrun [img]- case debugField out "switch" of- Just "none" -> pendingWith $ img ++ " has no runuser or sudo"- _ -> do- out' <- dryrun ["--user", "root", img]- assertUserSwitch out' "root"+ out <- dryrun ["--user", "root", img]+ debugField out "user" `shouldBe` Just "root"+ debugField out "switch" `shouldBe` Just "none"+ debugField out "container home" `shouldBe` Just "/root"+ out `shouldContain` "--user=root"+ out `shouldNotContain` "runuser -u root"+ out `shouldNotContain` "sudo -n --preserve-env -u root" + it "treats --root as shorthand for --user root" $+ withGenericImage $ \img -> do+ out <- dryrun ["--root", img]+ debugField out "user" `shouldBe` Just "root"+ debugField out "switch" `shouldBe` Just "none"+ debugField out "container home" `shouldBe` Just "/root"+ out `shouldContain` "--user=root"+ describe "ubuntu" $ do it "uses ubuntu user and passwd home for UID 1000" $ do img <- ubuntuImg@@ -157,18 +173,17 @@ pendingWith $ "image has uid user " ++ fromMaybe "unknown" other describe "sudo" $ do- it "writes sudoers when sudo is present, skips when not" $+ it "only writes sudoers when --sudo is given and sudo is present" $ withGenericImage $ \img -> do out <- dryrun [img]+ out `shouldNotContain` "NOPASSWD:ALL" case debugField out "sudo" of Just "True" -> do- out `shouldContain` "NOPASSWD:ALL"- outNo <- dryrun ["--no-sudo", img]- outNo `shouldNotContain` "NOPASSWD:ALL"+ outSudo <- dryrun ["--sudo", img]+ outSudo `shouldContain` "NOPASSWD:ALL" Just "False" -> do- out `shouldNotContain` "NOPASSWD:ALL"- outNo <- dryrun ["--no-sudo", img]- outNo `shouldNotContain` "NOPASSWD:ALL"+ outSudo <- dryrun ["--sudo", img]+ outSudo `shouldNotContain` "NOPASSWD:ALL" other -> expectationFailure $ "debug sudo line for " ++ img ++ " (got: " ++@@ -179,6 +194,12 @@ out <- encapsule ["enter", "--help"] out `shouldContain` "[--] CMD" + it "offers explicit user and root selection" $ do+ out <- encapsule ["enter", "--help"]+ out `shouldContain` "[--root | --user USER]"+ out `shouldContain` "--user USER"+ out `shouldContain` "--root"+ describe "commit" $ do it "offers --name" $ do out <- encapsule ["commit", "--help"]@@ -187,7 +208,7 @@ it "names the image encapsule-CONTAINER by default" $ withScratchContainer $ \cname -> do out <- encapsule ["commit", "--dryrun", cname]- out `shouldContain` "buildah commit"+ out `shouldContain` "podman commit" out `shouldContain` ("encapsule-" ++ cname) it "applies --name to the encapsule image" $