Skip to content

Commit 63f3a92

Browse files
authored
Merge pull request #87 from adamamyl/feat/docker-rootless
feat(docker): default to rootless Docker per user
2 parents cc201cb + 92e8866 commit 63f3a92

3 files changed

Lines changed: 211 additions & 17 deletions

File tree

lib/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@
8787
# Packages required for Docker (Based on Docker install script pre-reqs: ca-certificates curl)
8888
DOCKER_DEPS: List[str] = ["curl", "gnupg", "lsb-release", "ca-certificates"]
8989

90+
# newuidmap/newgidmap, required for rootless mode's user namespace UID/GID mapping
91+
ROOTLESS_DOCKER_DEPS: List[str] = ["uidmap"]
92+
9093
# Full modern Docker suite (Matching successful installation log)
9194
DOCKER_PKGS: List[str] = [
9295
"docker-ce",

lib/installer_utils/module_docker.py

Lines changed: 198 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import shutil
22
import platform
33
import os
4+
import time
45
from typing import List, Dict
56
import subprocess
67

78
from ..executor import Executor
89
from ..logger import log
9-
from ..constants import DOCKER_DEPS, DOCKER_PKGS
10+
from ..constants import DOCKER_DEPS, DOCKER_PKGS, ROOTLESS_DOCKER_DEPS
1011
from .apt_tools import apt_install, ensure_apt_repo
1112
from .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+
157340
def _verify_docker_installation(exec_obj: Executor) -> None:
158341
"""
159342
Runs 'docker info' and 'docker run hello-world' to verify installation, then cleans up.

setup_machine.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,13 @@ def parse_args() -> Tuple[argparse.Namespace, List[str]]:
8686
group_modules.add_argument("--tailscale", action="store_true", dest="do_tailscale",
8787
help="Install and configure Tailscale.")
8888
group_modules.add_argument("--docker", action="store_true", dest="do_docker",
89-
help="Install Docker and add users to the docker group.")
89+
help="Install Docker. Target user gets rootless Docker by "
90+
"default; see --docker-rootful to override.")
91+
group_modules.add_argument(
92+
"--docker-rootful", action="store_true", dest="do_docker_rootful",
93+
help="Use traditional rootful Docker (add user to the 'docker' group) "
94+
"instead of the rootless default."
95+
)
9096
group_modules.add_argument("--cloud-init", action="store_true", dest="do_cloud_init",
9197
help="Install system-level repos (post-cloud-init, etc.).")
9298
group_modules.add_argument("--firewall", action="store_true", dest="do_firewall",
@@ -373,7 +379,9 @@ def main() -> None:
373379
# Docker after users — ensures all user accounts are fully configured before group membership
374380
if tasks["docker"]:
375381
log_module_start("DOCKER", EXEC)
376-
module_docker.install_docker_and_add_users(EXEC, DEFAULT_VM_USER)
382+
module_docker.install_docker_and_add_users(
383+
EXEC, DEFAULT_VM_USER, rootless=not args.do_docker_rootful
384+
)
377385

378386
if tasks["wolfcraig"]:
379387
log_module_start("WOLFCRAIG SETUP", EXEC)

0 commit comments

Comments
 (0)