11import shutil
22import platform
33import os
4+ import time
45from typing import List , Dict
56import subprocess
67
78from ..executor import Executor
89from ..logger import log
9- from ..constants import DOCKER_DEPS , DOCKER_PKGS
10+ from ..constants import DOCKER_DEPS , DOCKER_PKGS , ROOTLESS_DOCKER_DEPS
1011from .apt_tools import apt_install , ensure_apt_repo
1112from .user_mgmt import add_user_to_group
1213
@@ -68,10 +69,22 @@ def _remove_old_docker(exec_obj: Executor) -> None:
6869 log .debug (f"Removal error: { e } " )
6970 # We don't halt here, as the subsequent installation step will fail if necessary.
7071
71- def install_docker_and_add_users (exec_obj : Executor , * users_to_add : str ) -> None :
72+ def install_docker_and_add_users (
73+ exec_obj : Executor , * users_to_add : str , rootless : bool = True
74+ ) -> None :
7275 """
73- Installs Docker packages, starts the service, and adds users to the 'docker' group .
76+ Installs Docker packages (system-wide daemon) and Docker Compose plugin .
7477 Supports both Ubuntu and Debian automatically.
78+
79+ By default each user in *users_to_add* gets their own rootless Docker
80+ daemon (per docs.docker.com/engine/security/rootless/) rather than being
81+ added to the 'docker' group, which is root-equivalent. The system-wide
82+ daemon is still installed/enabled regardless, since other modules
83+ (no2id-docker, ollama-docker) bind-mount /var/run/docker.sock and depend
84+ on it; rootless and rootful Docker coexist fine on the same host.
85+
86+ Pass rootless=False to restore the old behaviour of adding users to the
87+ 'docker' group instead.
7588 """
7689 # Safeguard for macOS
7790 if platform .system ().lower () == "darwin" :
@@ -93,8 +106,12 @@ def install_docker_and_add_users(exec_obj: Executor, *users_to_add: str) -> None
93106 # 1. Detect OS details using Python dictionary matching
94107 os_info = _get_os_release ()
95108 os_id = os_info .get ("ID" ) # e.g., 'ubuntu' or 'debian'
96- codename = os_info .get ("VERSION_CODENAME" ) # e.g., 'noble' or 'bookworm'
97-
109+ # Docker's Ubuntu install docs prefer UBUNTU_CODENAME over VERSION_CODENAME
110+ # (falling back to the latter): unofficial derivatives like Mint or Pop!_OS
111+ # report their own VERSION_CODENAME but still carry UBUNTU_CODENAME for the
112+ # underlying Ubuntu release Docker's repo actually publishes packages for.
113+ codename = os_info .get ("UBUNTU_CODENAME" ) or os_info .get ("VERSION_CODENAME" )
114+
98115 if not os_id or not codename :
99116 log .critical (
100117 "Could not detect OS ID or Codename from /etc/os-release. Aborting Docker setup."
@@ -103,6 +120,20 @@ def install_docker_and_add_users(exec_obj: Executor, *users_to_add: str) -> None
103120
104121 log .info (f"Detected OS: { os_id } , Codename: { codename } " )
105122
123+ # Docker's repo lags behind new Debian releases. Fall back to the last
124+ # known supported codename if the detected one isn't published yet.
125+ DEBIAN_DOCKER_FALLBACK = {
126+ "trixie" : "bookworm" ,
127+ "forky" : "trixie" , # Debian 14, future-proofing
128+ }
129+ if os_id == "debian" and codename in DEBIAN_DOCKER_FALLBACK :
130+ fallback = DEBIAN_DOCKER_FALLBACK [codename ]
131+ log .warning (
132+ f"Docker repo has no packages for Debian '{ codename } ' yet. "
133+ f"Using '{ fallback } ' repo (compatible binaries)."
134+ )
135+ codename = fallback
136+
106137 keyrings_dir = "/etc/apt/keyrings"
107138 docker_gpg_path = os .path .join (keyrings_dir , "docker.gpg" )
108139 list_file = "/etc/apt/sources.list.d/docker.list"
@@ -123,12 +154,8 @@ def install_docker_and_add_users(exec_obj: Executor, *users_to_add: str) -> None
123154
124155 arch = platform .machine ()
125156
126- # --- FIX: Architecture Correction (aarch64 -> arm64) ---
127- # If the detected arch is aarch64, use the APT standard 'arm64' for the repo line.
128- if arch == 'aarch64' :
129- display_arch = 'arm64'
130- else :
131- display_arch = arch
157+ ARCH_MAP = {"x86_64" : "amd64" , "aarch64" : "arm64" }
158+ display_arch = ARCH_MAP .get (arch , arch )
132159
133160 # 2. Interpolate the correct ID, codename and arch into the repository line
134161 repo_line = (
@@ -148,12 +175,168 @@ def install_docker_and_add_users(exec_obj: Executor, *users_to_add: str) -> None
148175
149176
150177 exec_obj .run ("groupadd -f docker" , force_sudo = True )
151- for user in users_to_add :
152- add_user_to_group (exec_obj , user , "docker" )
153- log .success (f"Added { user } to docker group." )
154-
178+
179+ if rootless :
180+ apt_install (exec_obj , ROOTLESS_DOCKER_DEPS )
181+ for user in users_to_add :
182+ _setup_rootless_docker (exec_obj , user )
183+ else :
184+ for user in users_to_add :
185+ add_user_to_group (exec_obj , user , "docker" )
186+ log .success (f"Added { user } to docker group." )
187+
155188 log .success ("Docker installation complete." )
156189
190+
191+ def _user_exists (user : str ) -> bool :
192+ return subprocess .run (
193+ ['id' , user ], stdout = subprocess .DEVNULL , stderr = subprocess .DEVNULL
194+ ).returncode == 0
195+
196+
197+ def _get_uid (user : str ) -> int :
198+ return int (subprocess .run (
199+ ['id' , '-u' , user ], capture_output = True , text = True , check = True
200+ ).stdout .strip ())
201+
202+
203+ def _get_homedir (user : str ) -> str :
204+ result = subprocess .run (
205+ ['getent' , 'passwd' , user ], capture_output = True , text = True , check = True
206+ )
207+ return result .stdout .strip ().split (':' )[5 ]
208+
209+
210+ def _ensure_subid_range (exec_obj : Executor , path : str , user : str ) -> None :
211+ """
212+ Ensures /etc/subuid or /etc/subgid has a 65536-wide range for user.
213+ Modern useradd assigns this automatically; this is a fallback for
214+ accounts created before that became the default, matching the
215+ guidance in Docker's get.docker.com/rootless install script.
216+ """
217+ try :
218+ with open (path ) as f :
219+ lines = f .read ().splitlines ()
220+ except FileNotFoundError :
221+ lines = []
222+
223+ if any (line .startswith (f"{ user } :" ) for line in lines ):
224+ log .info (f"{ path } already has a range for '{ user } '." )
225+ return
226+
227+ # Pick a start beyond any existing range so we don't collide with one.
228+ starts_and_sizes = []
229+ for line in lines :
230+ parts = line .split (':' )
231+ if len (parts ) == 3 :
232+ try :
233+ starts_and_sizes .append ((int (parts [1 ]), int (parts [2 ])))
234+ except ValueError :
235+ continue
236+ next_start = max ((start + size for start , size in starts_and_sizes ), default = 100000 )
237+ next_start = max (next_start , 100000 )
238+
239+ log .info (f"Adding subordinate ID range { next_start } :65536 for '{ user } ' in { path } ." )
240+ exec_obj .run (f"echo '{ user } :{ next_start } :65536' | tee -a { path } > /dev/null" , force_sudo = True )
241+
242+
243+ def _setup_rootless_docker (exec_obj : Executor , user : str ) -> None :
244+ """
245+ Configures rootless Docker for *user*: uidmap prerequisites, subuid/subgid
246+ ranges, lingering (so their systemd --user instance survives without an
247+ active login), then runs dockerd-rootless-setuptool.sh as that user.
248+
249+ --force is passed to the setuptool because the system-wide dockerd is
250+ intentionally left running for other services; rootless and rootful
251+ Docker run side by side using separate sockets.
252+ """
253+ if not _user_exists (user ):
254+ log .warning (f"User '{ user } ' does not exist; skipping rootless Docker setup." )
255+ return
256+
257+ _ensure_subid_range (exec_obj , "/etc/subuid" , user )
258+ _ensure_subid_range (exec_obj , "/etc/subgid" , user )
259+
260+ log .info (f"Enabling lingering for '{ user } ' so their user services survive logout/boot." )
261+ exec_obj .run (f"loginctl enable-linger { user } " , force_sudo = True )
262+
263+ uid = _get_uid (user )
264+ runtime_dir = f"/run/user/{ uid } "
265+
266+ # Lingering triggers systemd-logind to create the runtime dir; give it a
267+ # moment to appear rather than sleeping blindly.
268+ for _ in range (10 ):
269+ if os .path .isdir (runtime_dir ) or exec_obj .dry_run :
270+ break
271+ time .sleep (1 )
272+ else :
273+ log .warning (f"{ runtime_dir } did not appear after enabling linger; proceeding anyway." )
274+
275+ env_prefix = f"XDG_RUNTIME_DIR={ runtime_dir } PATH=/usr/bin:$PATH"
276+
277+ log .info (f"Running dockerd-rootless-setuptool.sh for '{ user } '..." )
278+ exec_obj .run (
279+ f"{ env_prefix } dockerd-rootless-setuptool.sh install --force" ,
280+ user = user ,
281+ check = True ,
282+ )
283+
284+ log .info (f"Enabling and starting the rootless docker.service for '{ user } '..." )
285+ exec_obj .run (
286+ f"XDG_RUNTIME_DIR={ runtime_dir } systemctl --user enable --now docker.service" ,
287+ user = user ,
288+ check = True ,
289+ )
290+
291+ _add_rootless_env_to_shell_rc (exec_obj , user , uid )
292+ _verify_rootless_docker (exec_obj , user , runtime_dir )
293+
294+
295+ def _add_rootless_env_to_shell_rc (exec_obj : Executor , user : str , uid : int ) -> None :
296+ """Adds the DOCKER_HOST export Docker's docs recommend to the user's ~/.bashrc (idempotent)."""
297+ marker = "# Added by machine-setup: rootless Docker"
298+ export_line = f'export DOCKER_HOST="unix:///run/user/{ uid } /docker.sock"'
299+
300+ try :
301+ bashrc = os .path .join (_get_homedir (user ), ".bashrc" )
302+ except subprocess .CalledProcessError :
303+ log .warning (f"Could not determine homedir for '{ user } '; skipping .bashrc update." )
304+ return
305+
306+ existing = ""
307+ if os .path .exists (bashrc ):
308+ with open (bashrc ) as f :
309+ existing = f .read ()
310+
311+ if marker in existing :
312+ log .info (f"{ bashrc } already configured for rootless Docker." )
313+ return
314+
315+ log .info (f"Adding DOCKER_HOST export to { bashrc } for '{ user } '." )
316+ block = f"\n { marker } \n { export_line } \n "
317+ exec_obj .run (f"printf '%s' '{ block } ' | tee -a { bashrc } > /dev/null" , force_sudo = True )
318+ exec_obj .run (f"chown { user } :{ user } { bashrc } " , force_sudo = True )
319+
320+
321+ def _verify_rootless_docker (exec_obj : Executor , user : str , runtime_dir : str ) -> None :
322+ """Runs 'docker info' as user against their rootless socket to confirm it's up."""
323+ try :
324+ result = exec_obj .run (
325+ f"XDG_RUNTIME_DIR={ runtime_dir } docker info" ,
326+ user = user ,
327+ check = True ,
328+ run_quiet = True ,
329+ )
330+ if "rootless" in result .stdout .lower ():
331+ log .success (f"Rootless Docker is running for '{ user } '." )
332+ else :
333+ log .warning (
334+ f"'docker info' succeeded for '{ user } ' but doesn't report rootless mode."
335+ )
336+ except subprocess .CalledProcessError as e :
337+ log .warning (f"Could not verify rootless Docker for '{ user } '." )
338+ log .debug (f"docker info error: { e } " )
339+
157340def _verify_docker_installation (exec_obj : Executor ) -> None :
158341 """
159342 Runs 'docker info' and 'docker run hello-world' to verify installation, then cleans up.
0 commit comments