public inbox for isar-users@googlegroups.com
 help / color / mirror / Atom feed
* [PATCH] image-account-extension: configure adduser UID/GID pools
@ 2026-05-21 16:21 'Cedric Hombourger' via isar-users
  2026-05-21 16:44 ` 'Jan Kiszka' via isar-users
  0 siblings, 1 reply; 4+ messages in thread
From: 'Cedric Hombourger' via isar-users @ 2026-05-21 16:21 UTC (permalink / raw)
  To: isar-users; +Cc: Cedric Hombourger

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.

A new 'reserve-only' flag allows entries to exist solely for pool
reservation without being explicitly created during image postprocessing.

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. We want to discuss this!
Do we want to create images from a template richer than bootstrap
so adduser could be pre-installed and possibly its configuration
already patched to use UID_POOL / GID_POOL?

Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
---
 doc/user_manual.md                            |  44 +++++--
 .../image-account-extension.bbclass           | 113 +++++++++++++++++-
 2 files changed, 145 insertions(+), 12 deletions(-)

diff --git a/doc/user_manual.md b/doc/user_manual.md
index 69e8dfef..3bd2e767 100644
--- a/doc/user_manual.md
+++ b/doc/user_manual.md
@@ -737,7 +737,8 @@ The `GROUP_<groupname>` variable contains the settings of a group named `groupna
 
  - `gid` - The numeric group id.
  - `flags` - A list of additional flags of the group. Those are the currently recognized flags:
-   - `system` - The group is created using the `--system` parameter.
+    - `system` - The group is created using the `--system` parameter.
+    - `reserve-only` - The group is not explicitly created during image postprocessing. Instead, its `gid` is reserved in the adduser GID pool so that packages creating this group via maintainer scripts will use the specified ID.
 
 The `USERS` and `USER:<username>` variable works similar to the `GROUPS` and `GROUP:<groupname>` variable. The difference are the accepted flags of the `USER:<username>` variable. It accepts the following flags:
 
@@ -750,13 +751,14 @@ The `USERS` and `USER:<username>` variable works similar to the `GROUPS` and `GR
  - `home` - This changes the default home directory of the user with `usermod --move-home`. Only takes effect when used together with the `create-home` flag.
  - `shell` - This users login shell
  - `groups` - A space separated list of groups this user is a member of.
- - `flags` - A list of additional flags of the user:
-   - `no-create-home` - `useradd` will be called with `-M` to prevent creation of the users home directory.
-   - `create-home` - `useradd` will be called with `-m` to force creation of the users home directory.
-   - `system` - `useradd` will be called with `--system`.
-   - `allow-empty-password` - Even if the `password` flag is empty, it will still be set. This results in a login without password.
-   - `clear-text-password` - The `password` flag of the given user contains a clear-text password and not an encrypted version of it.
-   - `force-passwd-change` - Force the user to change to password on first login.
+  - `flags` - A list of additional flags of the user:
+    - `no-create-home` - `useradd` will be called with `-M` to prevent creation of the users home directory.
+    - `create-home` - `useradd` will be called with `-m` to force creation of the users home directory.
+    - `system` - `useradd` will be called with `--system`.
+    - `allow-empty-password` - Even if the `password` flag is empty, it will still be set. This results in a login without password.
+    - `clear-text-password` - The `password` flag of the given user contains a clear-text password and not an encrypted version of it.
+    - `force-passwd-change` - Force the user to change to password on first login.
+    - `reserve-only` - The user is not explicitly created during image postprocessing. Instead, its `uid` is reserved in the adduser UID pool so that packages creating this user via maintainer scripts will use the specified ID.
 
 #### Example
 
@@ -779,6 +781,32 @@ USER_root[flags] = "create-home system force-passwd-change"
 
 Some examples can be also found in `meta-isar/conf/local.conf.sample`.
 
+#### UID/GID pool reservation
+
+When a user or group entry has an explicit `uid` or `gid` set, it is added to
+the adduser UID/GID pool. This ensures that packages creating users or groups
+via their maintainer scripts (e.g. `adduser` or `addgroup`) will allocate the
+specified IDs. Combined with the `reserve-only` flag, this allows reserving IDs
+without explicitly creating the accounts:
+
+```
+USERS += "tss"
+USER_tss[uid] = "666"
+USER_tss[flags] = "reserve-only"
+
+GROUPS += "tss"
+GROUP_tss[gid] = "666"
+GROUP_tss[flags] = "reserve-only"
+
+GROUPS += "docker"
+GROUP_docker[gid] = "1234"
+GROUP_docker[flags] = "reserve-only"
+```
+
+In this example, when `tpm2-abrmd` or `docker.io` are installed, their
+maintainer scripts will create the `tss` and `docker` accounts using the
+reserved IDs rather than dynamically allocated ones.
+
 #### 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..7dfcd8e0 100644
--- a/meta/classes-recipe/image-account-extension.bbclass
+++ b/meta/classes-recipe/image-account-extension.bbclass
@@ -14,16 +14,18 @@ python() {
     for entry in (d.getVar("GROUPS") or "").split():
         group_entry = "GROUP_{}".format(entry)
         d.appendVarFlag("image_postprocess_accounts", "vardeps", " {}".format(group_entry))
+        d.appendVarFlag("image_configure_adduser_pools", "vardeps", " {}".format(group_entry))
         d.appendVarFlag("do_rootfs_install", "vardeps", " {}".format(group_entry))
 
     for entry in (d.getVar("USERS") or "").split():
         user_entry = "USER_{}".format(entry)
         d.appendVarFlag("image_postprocess_accounts", "vardeps", " {}".format(user_entry))
+        d.appendVarFlag("image_configure_adduser_pools", "vardeps", " {}".format(user_entry))
         d.appendVarFlag("do_rootfs_install", "vardeps", " {}".format(user_entry))
 }
 do_rootfs_install[vardeps] += "GROUPS USERS"
 
-def image_create_groups(d: "DataSmart") -> None:
+def image_create_groups(d):
     """Creates the groups defined in the ``GROUPS`` bitbake variable.
 
     Args:
@@ -40,6 +42,10 @@ def image_create_groups(d: "DataSmart") -> None:
         args = []
         group_entry = "GROUP_{}".format(entry)
 
+        flags = (d.getVarFlag(group_entry, "flags") or "").split()
+        if "reserve-only" in flags:
+            continue
+
         with open("{}/etc/group".format(rootfsdir), "r") as group_file:
             exists = any(line.startswith("{}:".format(entry)) for line in group_file)
 
@@ -59,7 +65,7 @@ def image_create_groups(d: "DataSmart") -> None:
             bb.process.run([*chroot, "/usr/sbin/groupadd", *args, entry])
 
 
-def image_create_users(d: "DataSmart") -> None:
+def image_create_users(d):
     """Creates the users defined in the ``USERS`` bitbake variable.
 
     Args:
@@ -78,6 +84,10 @@ def image_create_users(d: "DataSmart") -> None:
         args = []
         user_entry = "USER_{}".format(entry)
 
+        flags = (d.getVarFlag(user_entry, "flags") or "").split()
+        if "reserve-only" in flags:
+            continue
+
         with open("{}/etc/passwd".format(rootfsdir), "r") as passwd_file:
             exists = any(line.startswith("{}:".format(entry)) for line in passwd_file)
 
@@ -99,8 +109,6 @@ def image_create_users(d: "DataSmart") -> None:
             args.append("--groups")
             args.append(','.join(groups))
 
-        flags = (d.getVarFlag(user_entry, "flags") or "").split()
-
         if exists:
             add_user_option("--home", "home")
             if d.getVarFlag(user_entry, "home") or "":
@@ -143,6 +151,103 @@ def image_create_users(d: "DataSmart") -> None:
             bb.process.run([*chroot, "/usr/bin/passwd", "--expire", entry])
 
 
+def configure_adduser_pools(d):
+    """Configures adduser UID/GID pools for users and groups with explicit IDs.
+
+    Creates pool files and a minimal /etc/adduser.conf with UID_POOL/GID_POOL
+    directives before package installation.
+
+    Args:
+        d (DataSmart): The bitbake datastore.
+
+    Returns:
+        None
+    """
+    import os
+    import tempfile
+
+    rootfsdir = d.getVar("ROOTFSDIR")
+    adduser_conf = "{}/etc/adduser.conf".format(rootfsdir)
+    uid_pool_path = "/etc/adduser-uid.pool"
+    gid_pool_path = "/etc/adduser-gid.pool"
+
+    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))
+
+    if not uid_pool_entries and not gid_pool_entries:
+        return
+
+    if uid_pool_entries:
+        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, "{}{}".format(rootfsdir, uid_pool_path)])
+        bb.process.run(
+            ["sudo", "chmod", "644", "{}{}".format(rootfsdir, uid_pool_path)])
+        os.unlink(tmp)
+
+    if gid_pool_entries:
+        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, "{}{}".format(rootfsdir, gid_pool_path)])
+        bb.process.run(
+            ["sudo", "chmod", "644", "{}{}".format(rootfsdir, gid_pool_path)])
+        os.unlink(tmp)
+
+    # Create /etc/adduser.conf with the upstream default content plus pool
+    # directives. We use --force-confold during package installation so that
+    # dpkg keeps this version when the adduser package is installed.
+    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 uid_pool_entries:
+        conf_lines.append("UID_POOL={}".format(uid_pool_path))
+    if gid_pool_entries:
+        conf_lines.append("GID_POOL={}".format(gid_pool_path))
+
+    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: 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.
+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() {
-- 
2.47.3

-- 
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/20260521162215.1348898-1-cedric.hombourger%40siemens.com.

^ permalink raw reply	[flat|nested] 4+ messages in thread

* Re: [PATCH] image-account-extension: configure adduser UID/GID pools
  2026-05-21 16:21 [PATCH] image-account-extension: configure adduser UID/GID pools 'Cedric Hombourger' via isar-users
@ 2026-05-21 16:44 ` 'Jan Kiszka' via isar-users
  2026-05-21 18:48   ` [RFC PATCH v2] " 'Cedric Hombourger' via isar-users
  0 siblings, 1 reply; 4+ messages in thread
From: 'Jan Kiszka' via isar-users @ 2026-05-21 16:44 UTC (permalink / raw)
  To: Cedric Hombourger, isar-users; +Cc: Quirin Gylstorff, Christian Storm

On 21.05.26 18:21, 'Cedric Hombourger' via isar-users wrote:
> 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.
> 
> A new 'reserve-only' flag allows entries to exist solely for pool
> reservation without being explicitly created during image postprocessing.
> 
> 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. We want to discuss this!
> Do we want to create images from a template richer than bootstrap
> so adduser could be pre-installed and possibly its configuration
> already patched to use UID_POOL / GID_POOL?
> 

This is a valuable starting point for (public) discussions, thanks!

Internally, we already thought about this, also considering to add a way
for deriving this preceeding of the UIDs/GIDs from a "version 1" run of
a build so that you do not have to collect and encode all the data
manually. This would also emulate a normal Debian system lifecycle:
Install a blank version, then add or upgrade packages, thus,
users/groups while maintaining the assignments of the initial installation.

Jan

> Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
> ---
>  doc/user_manual.md                            |  44 +++++--
>  .../image-account-extension.bbclass           | 113 +++++++++++++++++-
>  2 files changed, 145 insertions(+), 12 deletions(-)
> 
> diff --git a/doc/user_manual.md b/doc/user_manual.md
> index 69e8dfef..3bd2e767 100644
> --- a/doc/user_manual.md
> +++ b/doc/user_manual.md
> @@ -737,7 +737,8 @@ The `GROUP_<groupname>` variable contains the settings of a group named `groupna
>  
>   - `gid` - The numeric group id.
>   - `flags` - A list of additional flags of the group. Those are the currently recognized flags:
> -   - `system` - The group is created using the `--system` parameter.
> +    - `system` - The group is created using the `--system` parameter.
> +    - `reserve-only` - The group is not explicitly created during image postprocessing. Instead, its `gid` is reserved in the adduser GID pool so that packages creating this group via maintainer scripts will use the specified ID.
>  
>  The `USERS` and `USER:<username>` variable works similar to the `GROUPS` and `GROUP:<groupname>` variable. The difference are the accepted flags of the `USER:<username>` variable. It accepts the following flags:
>  
> @@ -750,13 +751,14 @@ The `USERS` and `USER:<username>` variable works similar to the `GROUPS` and `GR
>   - `home` - This changes the default home directory of the user with `usermod --move-home`. Only takes effect when used together with the `create-home` flag.
>   - `shell` - This users login shell
>   - `groups` - A space separated list of groups this user is a member of.
> - - `flags` - A list of additional flags of the user:
> -   - `no-create-home` - `useradd` will be called with `-M` to prevent creation of the users home directory.
> -   - `create-home` - `useradd` will be called with `-m` to force creation of the users home directory.
> -   - `system` - `useradd` will be called with `--system`.
> -   - `allow-empty-password` - Even if the `password` flag is empty, it will still be set. This results in a login without password.
> -   - `clear-text-password` - The `password` flag of the given user contains a clear-text password and not an encrypted version of it.
> -   - `force-passwd-change` - Force the user to change to password on first login.
> +  - `flags` - A list of additional flags of the user:
> +    - `no-create-home` - `useradd` will be called with `-M` to prevent creation of the users home directory.
> +    - `create-home` - `useradd` will be called with `-m` to force creation of the users home directory.
> +    - `system` - `useradd` will be called with `--system`.
> +    - `allow-empty-password` - Even if the `password` flag is empty, it will still be set. This results in a login without password.
> +    - `clear-text-password` - The `password` flag of the given user contains a clear-text password and not an encrypted version of it.
> +    - `force-passwd-change` - Force the user to change to password on first login.
> +    - `reserve-only` - The user is not explicitly created during image postprocessing. Instead, its `uid` is reserved in the adduser UID pool so that packages creating this user via maintainer scripts will use the specified ID.
>  
>  #### Example
>  
> @@ -779,6 +781,32 @@ USER_root[flags] = "create-home system force-passwd-change"
>  
>  Some examples can be also found in `meta-isar/conf/local.conf.sample`.
>  
> +#### UID/GID pool reservation
> +
> +When a user or group entry has an explicit `uid` or `gid` set, it is added to
> +the adduser UID/GID pool. This ensures that packages creating users or groups
> +via their maintainer scripts (e.g. `adduser` or `addgroup`) will allocate the
> +specified IDs. Combined with the `reserve-only` flag, this allows reserving IDs
> +without explicitly creating the accounts:
> +
> +```
> +USERS += "tss"
> +USER_tss[uid] = "666"
> +USER_tss[flags] = "reserve-only"
> +
> +GROUPS += "tss"
> +GROUP_tss[gid] = "666"
> +GROUP_tss[flags] = "reserve-only"
> +
> +GROUPS += "docker"
> +GROUP_docker[gid] = "1234"
> +GROUP_docker[flags] = "reserve-only"
> +```
> +
> +In this example, when `tpm2-abrmd` or `docker.io` are installed, their
> +maintainer scripts will create the `tss` and `docker` accounts using the
> +reserved IDs rather than dynamically allocated ones.
> +
>  #### 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..7dfcd8e0 100644
> --- a/meta/classes-recipe/image-account-extension.bbclass
> +++ b/meta/classes-recipe/image-account-extension.bbclass
> @@ -14,16 +14,18 @@ python() {
>      for entry in (d.getVar("GROUPS") or "").split():
>          group_entry = "GROUP_{}".format(entry)
>          d.appendVarFlag("image_postprocess_accounts", "vardeps", " {}".format(group_entry))
> +        d.appendVarFlag("image_configure_adduser_pools", "vardeps", " {}".format(group_entry))
>          d.appendVarFlag("do_rootfs_install", "vardeps", " {}".format(group_entry))
>  
>      for entry in (d.getVar("USERS") or "").split():
>          user_entry = "USER_{}".format(entry)
>          d.appendVarFlag("image_postprocess_accounts", "vardeps", " {}".format(user_entry))
> +        d.appendVarFlag("image_configure_adduser_pools", "vardeps", " {}".format(user_entry))
>          d.appendVarFlag("do_rootfs_install", "vardeps", " {}".format(user_entry))
>  }
>  do_rootfs_install[vardeps] += "GROUPS USERS"
>  
> -def image_create_groups(d: "DataSmart") -> None:
> +def image_create_groups(d):
>      """Creates the groups defined in the ``GROUPS`` bitbake variable.
>  
>      Args:
> @@ -40,6 +42,10 @@ def image_create_groups(d: "DataSmart") -> None:
>          args = []
>          group_entry = "GROUP_{}".format(entry)
>  
> +        flags = (d.getVarFlag(group_entry, "flags") or "").split()
> +        if "reserve-only" in flags:
> +            continue
> +
>          with open("{}/etc/group".format(rootfsdir), "r") as group_file:
>              exists = any(line.startswith("{}:".format(entry)) for line in group_file)
>  
> @@ -59,7 +65,7 @@ def image_create_groups(d: "DataSmart") -> None:
>              bb.process.run([*chroot, "/usr/sbin/groupadd", *args, entry])
>  
>  
> -def image_create_users(d: "DataSmart") -> None:
> +def image_create_users(d):
>      """Creates the users defined in the ``USERS`` bitbake variable.
>  
>      Args:
> @@ -78,6 +84,10 @@ def image_create_users(d: "DataSmart") -> None:
>          args = []
>          user_entry = "USER_{}".format(entry)
>  
> +        flags = (d.getVarFlag(user_entry, "flags") or "").split()
> +        if "reserve-only" in flags:
> +            continue
> +
>          with open("{}/etc/passwd".format(rootfsdir), "r") as passwd_file:
>              exists = any(line.startswith("{}:".format(entry)) for line in passwd_file)
>  
> @@ -99,8 +109,6 @@ def image_create_users(d: "DataSmart") -> None:
>              args.append("--groups")
>              args.append(','.join(groups))
>  
> -        flags = (d.getVarFlag(user_entry, "flags") or "").split()
> -
>          if exists:
>              add_user_option("--home", "home")
>              if d.getVarFlag(user_entry, "home") or "":
> @@ -143,6 +151,103 @@ def image_create_users(d: "DataSmart") -> None:
>              bb.process.run([*chroot, "/usr/bin/passwd", "--expire", entry])
>  
>  
> +def configure_adduser_pools(d):
> +    """Configures adduser UID/GID pools for users and groups with explicit IDs.
> +
> +    Creates pool files and a minimal /etc/adduser.conf with UID_POOL/GID_POOL
> +    directives before package installation.
> +
> +    Args:
> +        d (DataSmart): The bitbake datastore.
> +
> +    Returns:
> +        None
> +    """
> +    import os
> +    import tempfile
> +
> +    rootfsdir = d.getVar("ROOTFSDIR")
> +    adduser_conf = "{}/etc/adduser.conf".format(rootfsdir)
> +    uid_pool_path = "/etc/adduser-uid.pool"
> +    gid_pool_path = "/etc/adduser-gid.pool"
> +
> +    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))
> +
> +    if not uid_pool_entries and not gid_pool_entries:
> +        return
> +
> +    if uid_pool_entries:
> +        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, "{}{}".format(rootfsdir, uid_pool_path)])
> +        bb.process.run(
> +            ["sudo", "chmod", "644", "{}{}".format(rootfsdir, uid_pool_path)])
> +        os.unlink(tmp)
> +
> +    if gid_pool_entries:
> +        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, "{}{}".format(rootfsdir, gid_pool_path)])
> +        bb.process.run(
> +            ["sudo", "chmod", "644", "{}{}".format(rootfsdir, gid_pool_path)])
> +        os.unlink(tmp)
> +
> +    # Create /etc/adduser.conf with the upstream default content plus pool
> +    # directives. We use --force-confold during package installation so that
> +    # dpkg keeps this version when the adduser package is installed.
> +    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 uid_pool_entries:
> +        conf_lines.append("UID_POOL={}".format(uid_pool_path))
> +    if gid_pool_entries:
> +        conf_lines.append("GID_POOL={}".format(gid_pool_path))
> +
> +    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: 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.
> +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() {

-- 
Siemens AG, Foundational Technologies
Linux Expert Center

-- 
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/bc1f5efb-40ab-4740-b659-327709d4079b%40siemens.com.

^ permalink raw reply	[flat|nested] 4+ messages in thread

* [RFC PATCH v2] image-account-extension: configure adduser UID/GID pools
  2026-05-21 16:44 ` 'Jan Kiszka' via isar-users
@ 2026-05-21 18:48   ` 'Cedric Hombourger' via isar-users
  2026-05-22  9:41     ` 'Jan Kiszka' via isar-users
  0 siblings, 1 reply; 4+ messages in thread
From: 'Cedric Hombourger' via isar-users @ 2026-05-21 18:48 UTC (permalink / raw)
  To: isar-users; +Cc: Cedric Hombourger

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.

Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
---
 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
--- a/doc/user_manual.md
+++ b/doc/user_manual.md
@@ -737,7 +737,8 @@ The `GROUP_<groupname>` variable contains the settings of a group named `groupna
 
  - `gid` - The numeric group id.
  - `flags` - A list of additional flags of the group. Those are the currently recognized flags:
-   - `system` - The group is created using the `--system` parameter.
+    - `system` - The group is created using the `--system` parameter.
+    - `reserve-only` - The group is not explicitly created during image postprocessing. Instead, its `gid` is reserved in the adduser GID pool so that packages creating this group via maintainer scripts will use the specified ID.
 
 The `USERS` and `USER:<username>` variable works similar to the `GROUPS` and `GROUP:<groupname>` variable. The difference are the accepted flags of the `USER:<username>` variable. It accepts the following flags:
 
@@ -750,13 +751,14 @@ The `USERS` and `USER:<username>` variable works similar to the `GROUPS` and `GR
  - `home` - This changes the default home directory of the user with `usermod --move-home`. Only takes effect when used together with the `create-home` flag.
  - `shell` - This users login shell
  - `groups` - A space separated list of groups this user is a member of.
- - `flags` - A list of additional flags of the user:
-   - `no-create-home` - `useradd` will be called with `-M` to prevent creation of the users home directory.
-   - `create-home` - `useradd` will be called with `-m` to force creation of the users home directory.
-   - `system` - `useradd` will be called with `--system`.
-   - `allow-empty-password` - Even if the `password` flag is empty, it will still be set. This results in a login without password.
-   - `clear-text-password` - The `password` flag of the given user contains a clear-text password and not an encrypted version of it.
-   - `force-passwd-change` - Force the user to change to password on first login.
+  - `flags` - A list of additional flags of the user:
+    - `no-create-home` - `useradd` will be called with `-M` to prevent creation of the users home directory.
+    - `create-home` - `useradd` will be called with `-m` to force creation of the users home directory.
+    - `system` - `useradd` will be called with `--system`.
+    - `allow-empty-password` - Even if the `password` flag is empty, it will still be set. This results in a login without password.
+    - `clear-text-password` - The `password` flag of the given user contains a clear-text password and not an encrypted version of it.
+    - `force-passwd-change` - Force the user to change to password on first login.
+    - `reserve-only` - The user is not explicitly created during image postprocessing. Instead, its `uid` is reserved in the adduser UID pool so that packages creating this user via maintainer scripts will use the specified ID.
 
 #### Example
 
@@ -779,6 +781,61 @@ USER_root[flags] = "create-home system force-passwd-change"
 
 Some examples can be also found in `meta-isar/conf/local.conf.sample`.
 
+#### UID/GID pool reservation
+
+When a user or group entry has an explicit `uid` or `gid` set, it is added to
+the adduser UID/GID pool. This ensures that packages creating users or groups
+via their maintainer scripts (e.g. `adduser` or `addgroup`) will allocate the
+specified IDs. Combined with the `reserve-only` flag, this allows reserving IDs
+without explicitly creating the accounts:
+
+```
+USERS += "tss"
+USER_tss[uid] = "666"
+USER_tss[flags] = "reserve-only"
+
+GROUPS += "tss"
+GROUP_tss[gid] = "666"
+GROUP_tss[flags] = "reserve-only"
+
+GROUPS += "docker"
+GROUP_docker[gid] = "1234"
+GROUP_docker[flags] = "reserve-only"
+```
+
+In this example, when `tpm2-abrmd` or `docker.io` are installed, their
+maintainer scripts will create the `tss` and `docker` accounts using the
+reserved IDs rather than dynamically allocated ones.
+
+#### 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
--- a/meta/classes-recipe/image-account-extension.bbclass
+++ b/meta/classes-recipe/image-account-extension.bbclass
@@ -14,16 +14,18 @@ python() {
     for entry in (d.getVar("GROUPS") or "").split():
         group_entry = "GROUP_{}".format(entry)
         d.appendVarFlag("image_postprocess_accounts", "vardeps", " {}".format(group_entry))
+        d.appendVarFlag("image_configure_adduser_pools", "vardeps", " {}".format(group_entry))
         d.appendVarFlag("do_rootfs_install", "vardeps", " {}".format(group_entry))
 
     for entry in (d.getVar("USERS") or "").split():
         user_entry = "USER_{}".format(entry)
         d.appendVarFlag("image_postprocess_accounts", "vardeps", " {}".format(user_entry))
+        d.appendVarFlag("image_configure_adduser_pools", "vardeps", " {}".format(user_entry))
         d.appendVarFlag("do_rootfs_install", "vardeps", " {}".format(user_entry))
 }
 do_rootfs_install[vardeps] += "GROUPS USERS"
 
-def image_create_groups(d: "DataSmart") -> None:
+def image_create_groups(d):
     """Creates the groups defined in the ``GROUPS`` bitbake variable.
 
     Args:
@@ -40,6 +42,10 @@ def image_create_groups(d: "DataSmart") -> None:
         args = []
         group_entry = "GROUP_{}".format(entry)
 
+        flags = (d.getVarFlag(group_entry, "flags") or "").split()
+        if "reserve-only" in flags:
+            continue
+
         with open("{}/etc/group".format(rootfsdir), "r") as group_file:
             exists = any(line.startswith("{}:".format(entry)) for line in group_file)
 
@@ -59,7 +65,7 @@ def image_create_groups(d: "DataSmart") -> None:
             bb.process.run([*chroot, "/usr/sbin/groupadd", *args, entry])
 
 
-def image_create_users(d: "DataSmart") -> None:
+def image_create_users(d):
     """Creates the users defined in the ``USERS`` bitbake variable.
 
     Args:
@@ -78,6 +84,10 @@ def image_create_users(d: "DataSmart") -> None:
         args = []
         user_entry = "USER_{}".format(entry)
 
+        flags = (d.getVarFlag(user_entry, "flags") or "").split()
+        if "reserve-only" in flags:
+            continue
+
         with open("{}/etc/passwd".format(rootfsdir), "r") as passwd_file:
             exists = any(line.startswith("{}:".format(entry)) for line in passwd_file)
 
@@ -99,8 +109,6 @@ def image_create_users(d: "DataSmart") -> None:
             args.append("--groups")
             args.append(','.join(groups))
 
-        flags = (d.getVarFlag(user_entry, "flags") or "").split()
-
         if exists:
             add_user_option("--home", "home")
             if d.getVarFlag(user_entry, "home") or "":
@@ -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

-- 
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/20260521184852.1455458-1-cedric.hombourger%40siemens.com.

^ permalink raw reply	[flat|nested] 4+ messages in thread

* Re: [RFC PATCH v2] image-account-extension: configure adduser UID/GID pools
  2026-05-21 18:48   ` [RFC PATCH v2] " 'Cedric Hombourger' via isar-users
@ 2026-05-22  9:41     ` 'Jan Kiszka' via isar-users
  0 siblings, 0 replies; 4+ messages in thread
From: 'Jan Kiszka' via isar-users @ 2026-05-22  9:41 UTC (permalink / raw)
  To: Cedric Hombourger, isar-users

On 21.05.26 20:48, 'Cedric Hombourger' via isar-users wrote:
> 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.
> 

Didn't study all details yet, but this looks fairly appealing.

> 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.
> 
> Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
> ---
>  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
> --- a/doc/user_manual.md
> +++ b/doc/user_manual.md
> @@ -737,7 +737,8 @@ The `GROUP_<groupname>` variable contains the settings of a group named `groupna
>  
>   - `gid` - The numeric group id.
>   - `flags` - A list of additional flags of the group. Those are the currently recognized flags:
> -   - `system` - The group is created using the `--system` parameter.
> +    - `system` - The group is created using the `--system` parameter.
> +    - `reserve-only` - The group is not explicitly created during image postprocessing. Instead, its `gid` is reserved in the adduser GID pool so that packages creating this group via maintainer scripts will use the specified ID.
>  
>  The `USERS` and `USER:<username>` variable works similar to the `GROUPS` and `GROUP:<groupname>` variable. The difference are the accepted flags of the `USER:<username>` variable. It accepts the following flags:
>  
> @@ -750,13 +751,14 @@ The `USERS` and `USER:<username>` variable works similar to the `GROUPS` and `GR
>   - `home` - This changes the default home directory of the user with `usermod --move-home`. Only takes effect when used together with the `create-home` flag.
>   - `shell` - This users login shell
>   - `groups` - A space separated list of groups this user is a member of.
> - - `flags` - A list of additional flags of the user:
> -   - `no-create-home` - `useradd` will be called with `-M` to prevent creation of the users home directory.
> -   - `create-home` - `useradd` will be called with `-m` to force creation of the users home directory.
> -   - `system` - `useradd` will be called with `--system`.
> -   - `allow-empty-password` - Even if the `password` flag is empty, it will still be set. This results in a login without password.
> -   - `clear-text-password` - The `password` flag of the given user contains a clear-text password and not an encrypted version of it.
> -   - `force-passwd-change` - Force the user to change to password on first login.
> +  - `flags` - A list of additional flags of the user:
> +    - `no-create-home` - `useradd` will be called with `-M` to prevent creation of the users home directory.
> +    - `create-home` - `useradd` will be called with `-m` to force creation of the users home directory.
> +    - `system` - `useradd` will be called with `--system`.
> +    - `allow-empty-password` - Even if the `password` flag is empty, it will still be set. This results in a login without password.
> +    - `clear-text-password` - The `password` flag of the given user contains a clear-text password and not an encrypted version of it.
> +    - `force-passwd-change` - Force the user to change to password on first login.
> +    - `reserve-only` - The user is not explicitly created during image postprocessing. Instead, its `uid` is reserved in the adduser UID pool so that packages creating this user via maintainer scripts will use the specified ID.
>  
>  #### Example
>  
> @@ -779,6 +781,61 @@ USER_root[flags] = "create-home system force-passwd-change"
>  
>  Some examples can be also found in `meta-isar/conf/local.conf.sample`.
>  
> +#### UID/GID pool reservation
> +
> +When a user or group entry has an explicit `uid` or `gid` set, it is added to
> +the adduser UID/GID pool. This ensures that packages creating users or groups
> +via their maintainer scripts (e.g. `adduser` or `addgroup`) will allocate the
> +specified IDs. Combined with the `reserve-only` flag, this allows reserving IDs
> +without explicitly creating the accounts:
> +
> +```
> +USERS += "tss"
> +USER_tss[uid] = "666"
> +USER_tss[flags] = "reserve-only"
> +
> +GROUPS += "tss"
> +GROUP_tss[gid] = "666"
> +GROUP_tss[flags] = "reserve-only"
> +
> +GROUPS += "docker"
> +GROUP_docker[gid] = "1234"
> +GROUP_docker[flags] = "reserve-only"
> +```
> +
> +In this example, when `tpm2-abrmd` or `docker.io` are installed, their
> +maintainer scripts will create the `tss` and `docker` accounts using the
> +reserved IDs rather than dynamically allocated ones.
> +
> +#### 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
> --- a/meta/classes-recipe/image-account-extension.bbclass
> +++ b/meta/classes-recipe/image-account-extension.bbclass
> @@ -14,16 +14,18 @@ python() {
>      for entry in (d.getVar("GROUPS") or "").split():
>          group_entry = "GROUP_{}".format(entry)
>          d.appendVarFlag("image_postprocess_accounts", "vardeps", " {}".format(group_entry))
> +        d.appendVarFlag("image_configure_adduser_pools", "vardeps", " {}".format(group_entry))
>          d.appendVarFlag("do_rootfs_install", "vardeps", " {}".format(group_entry))
>  
>      for entry in (d.getVar("USERS") or "").split():
>          user_entry = "USER_{}".format(entry)
>          d.appendVarFlag("image_postprocess_accounts", "vardeps", " {}".format(user_entry))
> +        d.appendVarFlag("image_configure_adduser_pools", "vardeps", " {}".format(user_entry))
>          d.appendVarFlag("do_rootfs_install", "vardeps", " {}".format(user_entry))
>  }
>  do_rootfs_install[vardeps] += "GROUPS USERS"
>  
> -def image_create_groups(d: "DataSmart") -> None:
> +def image_create_groups(d):

This signature change/alignment should be factored out and argued about
separately.

>      """Creates the groups defined in the ``GROUPS`` bitbake variable.
>  
>      Args:
> @@ -40,6 +42,10 @@ def image_create_groups(d: "DataSmart") -> None:
>          args = []
>          group_entry = "GROUP_{}".format(entry)
>  
> +        flags = (d.getVarFlag(group_entry, "flags") or "").split()
> +        if "reserve-only" in flags:
> +            continue
> +
>          with open("{}/etc/group".format(rootfsdir), "r") as group_file:
>              exists = any(line.startswith("{}:".format(entry)) for line in group_file)
>  
> @@ -59,7 +65,7 @@ def image_create_groups(d: "DataSmart") -> None:
>              bb.process.run([*chroot, "/usr/sbin/groupadd", *args, entry])
>  
>  
> -def image_create_users(d: "DataSmart") -> None:
> +def image_create_users(d):
>      """Creates the users defined in the ``USERS`` bitbake variable.
>  
>      Args:
> @@ -78,6 +84,10 @@ def image_create_users(d: "DataSmart") -> None:
>          args = []
>          user_entry = "USER_{}".format(entry)
>  
> +        flags = (d.getVarFlag(user_entry, "flags") or "").split()
> +        if "reserve-only" in flags:
> +            continue
> +
>          with open("{}/etc/passwd".format(rootfsdir), "r") as passwd_file:
>              exists = any(line.startswith("{}:".format(entry)) for line in passwd_file)
>  
> @@ -99,8 +109,6 @@ def image_create_users(d: "DataSmart") -> None:
>              args.append("--groups")
>              args.append(','.join(groups))
>  
> -        flags = (d.getVarFlag(user_entry, "flags") or "").split()
> -
>          if exists:
>              add_user_option("--home", "home")
>              if d.getVarFlag(user_entry, "home") or "":
> @@ -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"
> +

Would be a valuable follow-up task then to work with upstream adduser to
overcome this. Specifically as we cannot confine this workaround to the
package or even files that need it.

> +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):

This is not C, but I think the pattern was so far defining functions
before they are used.

> +    """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]))

Hmm, should we filter out from those file the users/groups that are
coming from Isar itself? Our primary concern is about the
package-generated accounts.

Jan

-- 
Siemens AG, Foundational Technologies
Linux Expert Center

-- 
You received this message because you are subscribed to the Google Groups "isar-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to isar-users+unsubscribe@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/isar-users/b4fa45b6-456f-49f9-9813-9cada242d883%40siemens.com.

^ permalink raw reply	[flat|nested] 4+ messages in thread

end of thread, other threads:[~2026-05-22  9:41 UTC | newest]

Thread overview: 4+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-05-21 16:21 [PATCH] image-account-extension: configure adduser UID/GID pools 'Cedric Hombourger' via isar-users
2026-05-21 16:44 ` 'Jan Kiszka' via isar-users
2026-05-21 18:48   ` [RFC PATCH v2] " 'Cedric Hombourger' via isar-users
2026-05-22  9:41     ` 'Jan Kiszka' via isar-users

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox