For users and groups with an explicit uid/gid set, generate adduser pool
files so that maintainer scripts calling adduser/addgroup during package
installation will reserve the expected IDs.
Pool directories (/etc/adduser-uid.pool.d/ and /etc/adduser-gid.pool.d/)
are used, with a numbered fragment (00-image-accounts.conf) generated
from USERS/GROUPS entries. Additional .uid/.gid files from SRC_URI are
installed as numbered fragments, following the same pattern as .list
files for apt sources. Duplicates across fragments are filtered out
(USERS/GROUPS wins) with build warnings for traceability.
A new 'reserve-only' flag allows entries to exist solely for pool
reservation without being explicitly created during image postprocessing.
After postprocessing, ${IMAGE_FULLNAME}.uid and ${IMAGE_FULLNAME}.gid
are deployed to DEPLOY_DIR_IMAGE with all users/groups from the final
rootfs in adduser pool format.
Work-around: /etc/adduser.conf is pre-created with UID_POOL/GID_POOL
directives and --force-confold is passed to dpkg so that our version is
kept when the adduser package is installed. This is needed because
adduser does not support loading configuration fragments from a .d
directory or from environment variables.
doc/user_manual.md | 73 ++++-
.../image-account-extension.bbclass | 282 +++++++++++++++++-
2 files changed, 343 insertions(+), 12 deletions(-)
diff --git a/doc/user_manual.md b/doc/user_manual.md
index 69e8dfef..b5b54f64 100644
@@ -779,6 +781,61 @@ USER_root[flags] = "create-home system force-passwd-change"
+#### UID/GID pool files from SRC_URI
+
+Pool entries can also be provided via `.uid` and `.gid` files in `SRC_URI`.
+These files use the adduser pool format (`name:id`, one per line) and are
+installed as numbered fragments in `/etc/adduser-uid.pool.d/` and
+`/etc/adduser-gid.pool.d/` respectively.
+
+```
+SRC_URI += "file://my-accounts.uid file://my-accounts.gid"
+```
+
+Where `my-accounts.uid` might contain:
+
+```
+# Reserve UIDs for package-created users
+tss:666
+sshd:800
+```
+
+Entries from `USERS`/`GROUPS` (placed in `00-image-accounts.conf`) take
+priority over SRC_URI pool files. Duplicates are automatically filtered
+with a build warning indicating which entries were dropped and from which
+file.
+
+After image postprocessing, `${IMAGE_FULLNAME}.uid` and
+`${IMAGE_FULLNAME}.gid` files are deployed to `DEPLOY_DIR_IMAGE` containing
+all users and groups from the final rootfs. These files can be used as pool
+inputs for other images to maintain consistent UID/GID allocation.
+
#### Home directory contents prefilling
To cover all users simply use `/etc/skel`. Files in there will be available in every home directory under correct permissions.
diff --git a/meta/classes-recipe/image-account-extension.bbclass b/meta/classes-recipe/image-account-extension.bbclass
index e874f3c7..52eeec1b 100644
@@ -143,9 +151,275 @@ def image_create_users(d: "DataSmart") -> None:
bb.process.run([*chroot, "/usr/bin/passwd", "--expire", entry])
+def account_pool_files(d):
+ """Returns lists of .uid and .gid files found in SRC_URI."""
+ uid_files = []
+ gid_files = []
+ sources = d.getVar("SRC_URI").split()
+ for s in sources:
+ _, _, local, _, _, _ = bb.fetch.decodeurl(s)
+ base, ext = os.path.splitext(os.path.basename(local))
+ if ext == ".uid":
+ uid_files.append(local)
+ elif ext == ".gid":
+ gid_files.append(local)
+ return uid_files, gid_files
+
+
+def configure_adduser_pools(d):
+ """Configures adduser UID/GID pools for users and groups with explicit IDs.
+
+ Creates pool directories (/etc/adduser-uid.pool.d/ and
+ /etc/adduser-gid.pool.d/) containing:
+ - A numbered fragment (00-image-accounts.conf) generated from
+ USERS/GROUPS entries with explicit uid/gid.
+ - Additional .uid/.gid files from SRC_URI copied as numbered fragments.
+
+ A minimal /etc/adduser.conf is pre-created pointing UID_POOL and GID_POOL
+ at the respective directories.
+
+ Args:
+ d (DataSmart): The bitbake datastore.
+
+ Returns:
+ None
+ """
+ import os
+ import tempfile
+
+ rootfsdir = d.getVar("ROOTFSDIR")
+ workdir = d.getVar("WORKDIR")
+ adduser_conf = "{}/etc/adduser.conf".format(rootfsdir)
+ uid_pool_dir = "/etc/adduser-uid.pool.d"
+ gid_pool_dir = "/etc/adduser-gid.pool.d"
+
+ uid_pool_entries = []
+ seen_users = set()
+ for entry in (d.getVar("USERS") or "").split():
+ if entry in seen_users:
+ continue
+ seen_users.add(entry)
+ user_entry = "USER_{}".format(entry)
+ uid = d.getVarFlag(user_entry, "uid") or ""
+ if uid:
+ uid_pool_entries.append("{}:{}".format(entry, uid))
+
+ gid_pool_entries = []
+ seen_groups = set()
+ for entry in (d.getVar("GROUPS") or "").split():
+ if entry in seen_groups:
+ continue
+ seen_groups.add(entry)
+ group_entry = "GROUP_{}".format(entry)
+ gid = d.getVarFlag(group_entry, "gid") or ""
+ if gid:
+ gid_pool_entries.append("{}:{}".format(entry, gid))
+
+ # Collect .uid/.gid files from SRC_URI
+ src_uid_files, src_gid_files = account_pool_files(d)
+
+ has_uid_pool = uid_pool_entries or src_uid_files
+ has_gid_pool = gid_pool_entries or src_gid_files
+
+ if not has_uid_pool and not has_gid_pool:
+ return
+
+ # Create pool directories
+ if has_uid_pool:
+ bb.process.run(
+ ["sudo", "mkdir", "-p", "{}{}".format(rootfsdir, uid_pool_dir)])
+ if has_gid_pool:
+ bb.process.run(
+ ["sudo", "mkdir", "-p", "{}{}".format(rootfsdir, gid_pool_dir)])
+
+ # Track seen names and IDs to detect duplicates across fragments.
+ # 00-image-accounts.conf (from USERS/GROUPS) has highest priority.
+ uid_seen_names = set()
+ uid_seen_ids = set()
+ gid_seen_names = set()
+ gid_seen_ids = set()
+
+ # Install fragment from USERS/GROUPS as 00-image-accounts.conf
+ if uid_pool_entries:
+ for e in uid_pool_entries:
+ name, uid = e.split(":")
+ uid_seen_names.add(name)
+ uid_seen_ids.add(uid)
+ with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
+ f.write("\n".join(uid_pool_entries) + "\n")
+ tmp =
f.name
+ bb.process.run(
+ ["sudo", "cp", tmp,
+ "{}{}/00-image-accounts.conf".format(rootfsdir, uid_pool_dir)])
+ os.unlink(tmp)
+
+ if gid_pool_entries:
+ for e in gid_pool_entries:
+ name, gid = e.split(":")
+ gid_seen_names.add(name)
+ gid_seen_ids.add(gid)
+ with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
+ f.write("\n".join(gid_pool_entries) + "\n")
+ tmp =
f.name
+ bb.process.run(
+ ["sudo", "cp", tmp,
+ "{}{}/00-image-accounts.conf".format(rootfsdir, gid_pool_dir)])
+ os.unlink(tmp)
+
+ # Install .uid files from SRC_URI as numbered fragments, filtering
+ # duplicates. Keeping original filenames provides traceability.
+ for idx, uid_file in enumerate(src_uid_files, start=1):
+ src = os.path.join(workdir, uid_file)
+ filtered_lines = []
+ with open(src, "r") as f:
+ for line in f:
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#"):
+ filtered_lines.append(line)
+ continue
+ parts = stripped.split(":")
+ if len(parts) < 2:
+ filtered_lines.append(line)
+ continue
+ name, uid = parts[0], parts[1]
+ if name in uid_seen_names:
+ bb.warn("{}: dropping '{}' (name already in pool)"
+ .format(uid_file, stripped))
+ continue
+ if uid in uid_seen_ids:
+ bb.warn("{}: dropping '{}' (UID {} already in pool)"
+ .format(uid_file, stripped, uid))
+ continue
+ uid_seen_names.add(name)
+ uid_seen_ids.add(uid)
+ filtered_lines.append(line)
+
+ dst_name = "{:02d}-{}.conf".format(idx, os.path.splitext(uid_file)[0])
+ with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
+ f.writelines(filtered_lines)
+ tmp =
f.name
+ bb.process.run(
+ ["sudo", "cp", tmp, "{}{}/{}".format(rootfsdir, uid_pool_dir, dst_name)])
+ os.unlink(tmp)
+
+ # Install .gid files from SRC_URI as numbered fragments, filtering
+ # duplicates.
+ for idx, gid_file in enumerate(src_gid_files, start=1):
+ src = os.path.join(workdir, gid_file)
+ filtered_lines = []
+ with open(src, "r") as f:
+ for line in f:
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#"):
+ filtered_lines.append(line)
+ continue
+ parts = stripped.split(":")
+ if len(parts) < 2:
+ filtered_lines.append(line)
+ continue
+ name, gid = parts[0], parts[1]
+ if name in gid_seen_names:
+ bb.warn("{}: dropping '{}' (name already in pool)"
+ .format(gid_file, stripped))
+ continue
+ if gid in gid_seen_ids:
+ bb.warn("{}: dropping '{}' (GID {} already in pool)"
+ .format(gid_file, stripped, gid))
+ continue
+ gid_seen_names.add(name)
+ gid_seen_ids.add(gid)
+ filtered_lines.append(line)
+
+ dst_name = "{:02d}-{}.conf".format(idx, os.path.splitext(gid_file)[0])
+ with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
+ f.writelines(filtered_lines)
+ tmp =
f.name
+ bb.process.run(
+ ["sudo", "cp", tmp, "{}{}/{}".format(rootfsdir, gid_pool_dir, dst_name)])
+ os.unlink(tmp)
+
+ # Ensure pool directories are world-readable
+ if has_uid_pool:
+ bb.process.run(
+ ["sudo", "chmod", "-R", "a+rX", "{}{}".format(rootfsdir, uid_pool_dir)])
+ if has_gid_pool:
+ bb.process.run(
+ ["sudo", "chmod", "-R", "a+rX", "{}{}".format(rootfsdir, gid_pool_dir)])
+
+ # Work-around: pre-create /etc/adduser.conf with pool directives and use
+ # --force-confold so dpkg keeps our version when the adduser package is
+ # installed. This is needed because adduser does not support loading
+ # configuration from /etc/adduser.conf.d/ or from environment variables.
+ conf_lines = []
+ conf_lines.append("# /etc/adduser.conf: `adduser' configuration.")
+ conf_lines.append("# See adduser(8) and adduser.conf(5) for full documentation.")
+ conf_lines.append("")
+ if has_uid_pool:
+ conf_lines.append("UID_POOL={}".format(uid_pool_dir))
+ if has_gid_pool:
+ conf_lines.append("GID_POOL={}".format(gid_pool_dir))
+
+ with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
+ f.write("\n".join(conf_lines) + "\n")
+ tmp =
f.name
+ bb.process.run(["sudo", "cp", tmp, adduser_conf])
+ bb.process.run(["sudo", "chmod", "644", adduser_conf])
+ os.unlink(tmp)
+
+
+# Work-around: use --force-confold so dpkg keeps our pre-created
+# /etc/adduser.conf when the adduser package is installed.
+ROOTFS_APT_ARGS += "-o DPkg::Options::=--force-confold"
+
+ROOTFS_CONFIGURE_COMMAND += "image_configure_adduser_pools"
+image_configure_adduser_pools[vardeps] += "USERS GROUPS"
+python image_configure_adduser_pools() {
+ configure_adduser_pools(d)
+}
+
ROOTFS_POSTPROCESS_COMMAND += "image_postprocess_accounts"
image_postprocess_accounts[vardeps] += "USERS GROUPS"
python image_postprocess_accounts() {
image_create_groups(d)
image_create_users(d)
+ image_deploy_id_pools(d)
}
+
+
+def image_deploy_id_pools(d):
+ """Deploys UID/GID pool files from the final rootfs to DEPLOY_DIR_IMAGE.
+
+ Generates ${IMAGE_FULLNAME}.uid and ${IMAGE_FULLNAME}.gid files in
+ adduser pool format (name:id) from /etc/passwd and /etc/group.
+
+ Args:
+ d (DataSmart): The bitbake datastore.
+
+ Returns:
+ None
+ """
+ import os
+
+ rootfsdir = d.getVar("ROOTFSDIR")
+ deploy_dir = d.getVar("DEPLOY_DIR_IMAGE")
+ image_fullname = d.getVar("IMAGE_FULLNAME")
+
+ os.makedirs(deploy_dir, exist_ok=True)
+
+ # Generate .uid from /etc/passwd
+ uid_file = os.path.join(deploy_dir, "{}.uid".format(image_fullname))
+ with open("{}/etc/passwd".format(rootfsdir), "r") as f:
+ with open(uid_file, "w") as out:
+ for line in f:
+ fields = line.strip().split(":")
+ if len(fields) >= 3:
+ out.write("{}:{}\n".format(fields[0], fields[2]))
+
+ # Generate .gid from /etc/group
+ gid_file = os.path.join(deploy_dir, "{}.gid".format(image_fullname))
+ with open("{}/etc/group".format(rootfsdir), "r") as f:
+ with open(gid_file, "w") as out:
+ for line in f:
+ fields = line.strip().split(":")
+ if len(fields) >= 3:
+ out.write("{}:{}\n".format(fields[0], fields[2]))
--
2.47.3