Merge pull request #1736 from anderbubble/syncuser-improvements

syncuser improvements
This commit is contained in:
Christian Goll
2025-02-14 08:47:13 +01:00
committed by GitHub
22 changed files with 243 additions and 99 deletions

View File

@@ -11,11 +11,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Added missing hostlist support for `wwctl node` and `wwctl overlay build`. #1635
- Added support for comma-separated hostlist patterns. #1635
- Added default value for `warewulf.conf:dhcp.template`. #1725
- Added `UniqueField` template function. #829
- Added `wwctl image build --syncuser`. #1321
### Changed
- Hide internal `wwctl completion` and `wwctl genconfig` commands. #1716
- Make .ww suffix optional during `wwctl overlay show --render`. #649
- DHCP template generates as much of the subnet and range definition as possible. #1469
- Updated overlay flags to `wwctl <node|profile> <add|set> [--runtime-overlays|--system-overlays]`. #1495
- syncuser overlay reads host passwd and group database from sysconfdir. #1736
- syncuser overlay skips duplicate users and groups in passwd and group databases. #829
- `wwctl image syncuser --write` is true by default. #1736
- Update syncuser documentation. #1736
### Fixed
@@ -30,11 +38,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Removed partial support for regex searches in node and profile lists. #1635
- Remove redundant `wwctl genconfig completions` command. #1716
### Changed
- DHCP template generates as much of the subnet and range definition as possible. #1469
- Updated overlay flags to `wwctl <node|profile> <add|set> [--runtime-overlays|--system-overlays]`. #1495
- Remove syncuser warning messages in `wwctl` that assume its use. #1321
- Remove syncuser from the list of default runtime overlays. #1322
## v4.6.0rc2, 2025-02-07

View File

@@ -4,7 +4,6 @@ nodeprofiles:
runtime overlay:
- hosts
- ssh.authorized_keys
- syncuser
system overlay:
- wwinit
- wwclient

View File

@@ -1,16 +1,27 @@
package build
import (
"fmt"
"github.com/spf13/cobra"
"github.com/warewulf/warewulf/internal/pkg/api/image"
apiimage "github.com/warewulf/warewulf/internal/pkg/api/image"
"github.com/warewulf/warewulf/internal/pkg/api/routes/wwapiv1"
"github.com/warewulf/warewulf/internal/pkg/image"
)
func CobraRunE(cmd *cobra.Command, args []string) error {
if SyncUser {
for _, name := range args {
if err := image.Syncuser(name, true); err != nil {
return fmt.Errorf("syncuser error: %w", err)
}
}
}
cbp := &wwapiv1.ImageBuildParameter{
ImageNames: args,
Force: BuildForce,
All: BuildAll,
}
return image.ImageBuild(cbp)
return apiimage.ImageBuild(cbp)
}

View File

@@ -23,11 +23,13 @@ var (
}
BuildForce bool
BuildAll bool
SyncUser bool
)
func init() {
baseCmd.PersistentFlags().BoolVarP(&BuildAll, "all", "a", false, "(re)Build all images")
baseCmd.PersistentFlags().BoolVarP(&BuildForce, "force", "f", false, "Force rebuild, even if it isn't necessary")
baseCmd.PersistentFlags().BoolVar(&SyncUser, "syncuser", false, "Synchronize UIDs/GIDs from host to image")
}
// GetRootCommand returns the root cobra.Command for the application.

View File

@@ -130,9 +130,6 @@ func CobraRunE(cmd *cobra.Command, args []string) error {
afterPasswdTime := getTime(path.Join(imagePath, "/etc/passwd"))
wwlog.Debug("passwdTime: %v", afterPasswdTime)
if beforePasswdTime.Before(afterPasswdTime) {
if !SyncUser {
wwlog.Warn("/etc/passwd has been modified, maybe you want to run syncuser")
}
userdbChanged = true
}
}
@@ -140,16 +137,16 @@ func CobraRunE(cmd *cobra.Command, args []string) error {
afterGroupTime := getTime(path.Join(imagePath, "/etc/group"))
wwlog.Debug("groupTime: %v", afterGroupTime)
if beforeGroupTime.Before(afterGroupTime) {
if !SyncUser {
wwlog.Warn("/etc/group has been modified, maybe you want to run syncuser")
}
userdbChanged = true
}
}
if userdbChanged && SyncUser {
err = image.SyncUids(imageName, false)
if err != nil {
wwlog.Error("Error in user sync, fix error and run 'syncuser' manually: %s", err)
if SyncUser {
if userdbChanged {
if err = image.Syncuser(imageName, false); err != nil {
wwlog.Error("syncuser error: %s", err)
}
} else {
wwlog.Info("Skipping syncuser (passwd and group files not changed)")
}
}

View File

@@ -15,14 +15,14 @@ func CobraRunE(cmd *cobra.Command, args []string) error {
if !image.ValidName(imageName) {
return fmt.Errorf("%s is not a valid image", imageName)
}
err := image.SyncUids(imageName, write)
err := image.Syncuser(imageName, write)
if err != nil {
return fmt.Errorf("error in synchronize: %s", err)
}
if write && !build {
// when write = true and build = false, we will print a warnning, this is the default case
wwlog.Warn("Syncuser is completed, please remember to rebuild image or add `--build` flag for automatic rebuild after syncuser")
wwlog.Warn("Syncuser is completed. Rebuild image or add `--build` flag for automatic rebuild after syncuser.")
} else if write && build {
// if write = true and build = true, then it'll trigger the image build after sync
cbp := &wwapiv1.ImageBuildParameter{

View File

@@ -29,7 +29,7 @@ uid/gid collision is detected. File ownerships are also changed.`,
)
func init() {
baseCmd.PersistentFlags().BoolVar(&write, "write", false, "Synchronize uis/gids and write files in image")
baseCmd.PersistentFlags().BoolVar(&write, "write", true, "Synchronize uis/gids and write files in image")
baseCmd.PersistentFlags().BoolVar(&build, "build", false, "Build image after syncuser is completed")
}

View File

@@ -193,9 +193,9 @@ func ImageImport(cip *wwapiv1.ImageImportParameter) (imageName string, err error
}
if cip.SyncUser {
err = image.SyncUids(cip.Name, true)
err = image.Syncuser(cip.Name, true)
if err != nil {
err = fmt.Errorf("error in user sync, fix error and run 'syncuser' manually: %s", err)
err = fmt.Errorf("syncuser error: %w", err)
return
}
}

View File

@@ -12,14 +12,12 @@ import (
"syscall"
"github.com/pkg/errors"
"github.com/warewulf/warewulf/internal/pkg/config"
"github.com/warewulf/warewulf/internal/pkg/util"
"github.com/warewulf/warewulf/internal/pkg/wwlog"
)
const passwdPath = "/etc/passwd"
const groupPath = "/etc/group"
// SyncUids updates the /etc/passwd and /etc/group files in the
// Syncuser updates the /etc/passwd and /etc/group files in the
// image identified by imageName by installing the equivalent
// files from the host and appending names only in the
// image. Files in the image are updated to match the new
@@ -33,14 +31,15 @@ const groupPath = "/etc/group"
// A conflict arises if the image has an entry with the same id as
// an entry in the host and the host does not have an entry with the
// same name. In this case, an error is returned.
func SyncUids(imageName string, write bool) error {
wwlog.Debug("SyncUids(imageName=%v, write=%v)", imageName, write)
func Syncuser(imageName string, write bool) error {
wwlog.Debug("Syncuser(imageName=%v, write=%v)", imageName, write)
conf := config.Get()
imagePath := RootFsDir(imageName)
imagePasswdPath := path.Join(imagePath, passwdPath)
imageGroupPath := path.Join(imagePath, groupPath)
imagePasswdPath := path.Join(imagePath, "/etc/passwd")
imageGroupPath := path.Join(imagePath, "/etc/group")
passwdSync := make(syncDB)
if err := passwdSync.readFromHost(passwdPath); err != nil {
if err := passwdSync.readFromHost(path.Join(conf.Paths.Sysconfdir, "passwd")); err != nil {
return err
}
if err := passwdSync.readFromimage(imagePasswdPath); err != nil {
@@ -51,7 +50,7 @@ func SyncUids(imageName string, write bool) error {
}
groupSync := make(syncDB)
if err := groupSync.readFromHost(groupPath); err != nil {
if err := groupSync.readFromHost(path.Join(conf.Paths.Sysconfdir, "group")); err != nil {
return err
}
if err := groupSync.readFromimage(imageGroupPath); err != nil {
@@ -78,16 +77,16 @@ func SyncUids(imageName string, write bool) error {
if err := groupSync.chownGroupFiles(); err != nil {
return err
}
if err := passwdSync.update(imagePasswdPath, passwdPath); err != nil {
if err := passwdSync.update(imagePasswdPath, "/etc/passwd"); err != nil {
return err
}
if err := groupSync.update(imageGroupPath, groupPath); err != nil {
if err := groupSync.update(imageGroupPath, "/etc/group"); err != nil {
return err
}
wwlog.Info("uid/gid synced for image %s", imageName)
} else {
if passwdSync.needsSync() || groupSync.needsSync() {
wwlog.Info("uid/gid not synced: run `wwctl image syncuser --write %s`", imageName)
wwlog.Info("uid/gid not synced: run `wwctl image syncuser --write=true %s` to synchronize", imageName)
} else {
wwlog.Info("uid/gid already synced")
}

View File

@@ -130,3 +130,34 @@ func importSoftlink(lnk string) string {
func softlink(target string) string {
return fmt.Sprintf("{{ /* softlink \"%s\" */ }}", target)
}
// UniqueField returns a filtered version of a multi-line input string. input is
// expected to be a field-separated format with one record per line (terminated
// by `\n`). Order of lines is preserved, with the first matching line taking
// precedence.
//
// For example, parsing /etc/passwd filter /etc/passwd for unique user names:
//
// Lines without the index field (e.g., blank lines) are always included in the
// output.
//
// UniqueField(":", 0, passwdContent)
func UniqueField(sep string, index int, input string) string {
inputLines := strings.Split(input, "\n")
var outputLines []string
found := make(map[string]bool)
for _, line := range inputLines {
inputFields := strings.Split(line, sep)
if len(inputFields) > index {
field := inputFields[index]
if field != "" {
if found[field] {
continue
}
found[field] = true
}
}
outputLines = append(outputLines, line)
}
return strings.Join(outputLines, "\n")
}

View File

@@ -59,3 +59,50 @@ func Test_createIgnitionJson(t *testing.T) {
node := nodeInfos[0]
assert.JSONEq(t, expected_json, createIgnitionJson(&node))
}
func Test_UniqueField(t *testing.T) {
tests := map[string]struct {
input string
sep string
field int
output string
}{
"empty": {
input: ``,
sep: ":",
field: 0,
output: ``,
},
"unique input": {
input: `
name1:aaaa
name2:bbbb
`,
sep: ":",
field: 0,
output: `
name1:aaaa
name2:bbbb
`,
},
"duplicate field": {
input: `
name1: aaaa
name1: bbbb
`,
sep: ":",
field: 0,
output: `
name1: aaaa
`,
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tt.output, UniqueField(tt.sep, tt.field, tt.input))
})
}
}

View File

@@ -500,6 +500,7 @@ func RenderTemplateFile(fileName string, data TemplateStruct) (
backupFile = false
return ""
},
"UniqueField": UniqueField,
}
// Merge sprig.FuncMap with our FuncMap

View File

@@ -1,2 +1,7 @@
nodes:
node1: {}
node1:
image name: rockylinux-9
node2:
image name: rockylinux-9
tags:
PasswordlessRoot: true

View File

@@ -11,35 +11,61 @@ import (
)
func Test_syncuserOverlay(t *testing.T) {
t.Skip("syncuser is not yet isolated from the host")
env := testenv.New(t)
defer env.RemoveAll()
env.ImportFile("etc/warewulf/nodes.conf", "nodes.conf")
env.ImportFile("var/lib/warewulf/overlays/syncuser/rootfs/etc/passwd.ww", "../../../../../overlays/syncuser/rootfs/etc/passwd.ww")
env.ImportFile("var/lib/warewulf/overlays/syncuser/rootfs/etc/group.ww", "../../../../../overlays/syncuser/rootfs/etc/group.ww")
env.WriteFile("var/lib/warewulf/chroots/rockylinux-9/rootfs/etc/passwd", `root:x:0:0:root:/root:/bin/bash`)
env.WriteFile("var/lib/warewulf/chroots/rockylinux-9/rootfs/etc/group", `root:x:0:`)
env.ImportFile("var/lib/warewulf/overlays/syncuser/rootfs/etc/passwd.ww", "../rootfs/etc/passwd.ww")
env.ImportFile("var/lib/warewulf/overlays/syncuser/rootfs/etc/group.ww", "../rootfs/etc/group.ww")
env.WriteFile("etc/passwd", `
root:x:0:0:root:/root:/bin/bash
user:x:1000:1000:user:/home/user:/bin/bash
`)
env.WriteFile("etc/group", `
root:x:0:
user:x:1000:
`)
env.WriteFile("var/lib/warewulf/chroots/rockylinux-9/rootfs/etc/passwd", `
root:x:0:0:root:/root:/bin/bash
`)
env.WriteFile("var/lib/warewulf/chroots/rockylinux-9/rootfs/etc/group", `
root:x:0:
`)
tests := []struct {
name string
tests := map[string]struct {
args []string
log string
}{
{
name: "syncuser:passwd.ww",
"syncuser:passwd.ww": {
args: []string{"--render", "node1", "syncuser", "etc/passwd.ww"},
log: syncuser_passwd,
log: `backupFile: true
writeFile: true
Filename: etc/passwd
root:x:0:0:root:/root:/bin/bash
user:x:1000:1000:user:/home/user:/bin/bash
`,
},
{
name: "syncuser:group.ww",
"syncuser:passwd.ww (passwordless root)": {
args: []string{"--render", "node2", "syncuser", "etc/passwd.ww"},
log: `backupFile: true
writeFile: true
Filename: etc/passwd
root::0:0:root:/root:/bin/bash
user:x:1000:1000:user:/home/user:/bin/bash
`,
},
"syncuser:group.ww": {
args: []string{"--render", "node1", "syncuser", "etc/group.ww"},
log: syncuser_group,
log: `backupFile: true
writeFile: true
Filename: etc/group
root:x:0:
user:x:1000:
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
cmd := show.GetCommand()
cmd.SetArgs(tt.args)
stdout := bytes.NewBufferString("")
@@ -56,17 +82,3 @@ func Test_syncuserOverlay(t *testing.T) {
})
}
}
const syncuser_passwd string = `backupFile: true
writeFile: true
Filename: etc/passwd
# Uncomment the following line to enable passwordless root login
# root::0:0:root:/root:/bin/bash
root:x:0:0:root:/root:/bin/bash
`
const syncuser_group string = `backupFile: true
writeFile: true
Filename: etc/group
root:x:0:
`

View File

@@ -1,2 +1,6 @@
{{IncludeFrom $.ImageName "/etc/group"}}
{{Include "/etc/group"}}
{{
printf "%s\n%s"
(IncludeFrom $.ImageName "/etc/group" | trim)
(Include (printf "%s/%s" .Paths.Sysconfdir "group") | trim)
| UniqueField ":" 0 | trim
}}

View File

@@ -1,4 +1,7 @@
# Uncomment the following line to enable passwordless root login
# root::0:0:root:/root:/bin/bash
{{IncludeFrom $.ImageName "/etc/passwd"}}
{{Include "/etc/passwd"}}
{{- $sources := list }}
{{- if and .Tags.PasswordlessRoot (eq (lower .Tags.PasswordlessRoot) "true") }}
{{- $sources = append $sources "root::0:0:root:/root:/bin/bash" }}
{{- end }}
{{- $sources = append $sources (IncludeFrom $.ImageName "/etc/passwd" | trim) }}
{{- $sources = append $sources (Include (printf "%s/%s" .Paths.Sysconfdir "passwd") | trim) }}
{{- join "\n" $sources | UniqueField ":" 0 | trim }}

View File

@@ -196,27 +196,30 @@ See ProxyFromEnvironment_ For more information.
Syncuser
========
At import time Warewulf checks if the names of the users on the host
match the users and UIDs/GIDs in the imported image. If there is
mismatch, the import command will print out a warning. By setting the
``--syncuser`` flag you advise Warewulf to try to syncronize the users
from the host to the image, which means that ``/etc/passwd`` and
``/etc/group`` of the imported image are updated and all the files
belonging to these UIDs and GIDs will also be updated.
Warewulf can optionally synchronize UIDs and GIDs from the Warewulf server to an
image. This can be particularly useful when there is no central directory (e.g.,
an LDAP server). Some system services (notably "munge") require a user to have
the same UID across all nodes.
A check if the users of the host and image matches can be
triggered with the ``syncuser`` command.
With the addition of the "syncuser" overlay, Warewulf syncuser also supports
defining local users on the Warewulf server for synchronization to cluster
nodes.
If there is mismatch between the server and the image, the import command will
print out a warning.
Syncuser may be invoked during image import, exec, shell, or build.
.. code-block:: console
# wwctl image syncuser image-name
# wwctl image import --syncuser docker://ghcr.io/warewulf/warewulf-rockylinux:9 rockylinux-9
# wwctl image exec --syncuser rockylinux-9 -- /usr/bin/echo "Hello, world!"
# wwctl image shell --syncuser rockylinux-9
# wwctl image build --syncuser rockylinux-9
# wwctl image syncuser rockylinux-9
With the ``--write`` flag it will update the image to match the
user database of the host as described above.
.. code-block:: console
wwctl image syncuser --write image-name
After syncuser, ``/etc/passwd`` and ``/etc/group`` in the image are updated, and
permissions on files belonging to these UIDs and GIDs are updated to match.
Listing All Imported Images
===========================

View File

@@ -103,7 +103,7 @@ Node View
n001 cluster -- --
n001 image default sle-micro-5.3
n001 ipxe -- (default)
n001 runtime -- (hosts,ssh.authorized_keys,syncuser)
n001 runtime -- (hosts,ssh.authorized_keys)
n001 wwinit -- (wwinit,wwclient,fstab,hostname,ssh.host_keys,issue,resolv,udev.netname,systemd.netname,ifcfg,NetworkManager,debian.interfaces,wicked,ignition)
n001 root -- (initramfs)
n001 discoverable -- --

View File

@@ -87,7 +87,7 @@ You can also see the node's full attribute list by specifying the
n001 cluster -- --
n001 image default sle-micro-5.3
n001 ipxe -- (default)
n001 runtime -- (hosts,ssh.authorized_keys,syncuser)
n001 runtime -- (hosts,ssh.authorized_keys)
n001 wwinit -- (wwinit,wwclient,fstab,hostname,ssh.host_keys,issue,resolv,udev.netname,systemd.netname,ifcfg,NetworkManager,debian.interfaces,wicked,ignition)
n001 root -- (initramfs)
n001 discoverable -- --
@@ -165,7 +165,7 @@ image and network:
n001 cluster -- --
n001 image default sle-micro-5.3
n001 ipxe -- (default)
n001 runtime -- (hosts,ssh.authorized_keys,syncuser)
n001 runtime -- (hosts,ssh.authorized_keys)
n001 wwinit -- (wwinit,wwclient,fstab,hostname,ssh.host_keys,issue,resolv,udev.netname,systemd.netname,ifcfg,NetworkManager,debian.interfaces,wicked,ignition)
n001 root -- (initramfs)
n001 discoverable -- --

View File

@@ -157,9 +157,15 @@ syncuser
--------
The **syncuser** overlay updates ``/etc/passwd`` and ``/etc/group`` to include
all users on both the Warewulf server and from the image. To function
properly, ``wwctl image syncuser`` must have also been run on the image
image to synchronize its user and group IDs with those of the server.
all users on both the Warewulf server and from the image.
To function properly, ``wwctl image syncuser`` (or the ``--syncuser`` option
during ``import``, ``exec``, ``shell``, or ``build``) must have also been run on
the image to synchronize its user and group IDs with those of the server.
If a ``PasswordlessRoot`` tag is set to "true", the overlay will also insert a
"passwordless" root entry. This can be particularly useful for accessing a
cluster node when its network interface is not properly configured.
ignition
--------

View File

@@ -118,7 +118,7 @@ You can check the result with ``wwctl node list``.
deliverynet Comment default This profile is automatically included for each node
deliverynet ImageName default leap15.5
deliverynet Ipxe -- (default)
deliverynet RuntimeOverlay -- (hosts,ssh.authorized_keys,syncuser)
deliverynet RuntimeOverlay -- (hosts,ssh.authorized_keys)
deliverynet SystemOverlay -- (wwinit,wwclient,fstab,hostname,ssh.host_keys,issue,resolv,udev.netname,systemd.netname,ifcfg,NetworkManager,debian.interfaces,wicked,ignition)
deliverynet Root -- (initramfs)
deliverynet Init -- (/sbin/init)

View File

@@ -135,13 +135,13 @@ The following template will create a file called
</interface>
{{ end -}}
Special Commands
----------------
Special Functions
-----------------
Include
^^^^^^^
A file from the host can be include with following template
A file from the host can be included with following template
.. code-block::
@@ -228,6 +228,25 @@ readlink
Evaluates the soft link on the Warewulf server and returns the target.
UniqueField
^^^^^^^^^^^
UniqueField returns a filtered version of a multi-line input string. input is
expected to be a field-separated format with one record per line (terminated by
`\n`). Order of lines is preserved, with the first matching line taking
precedence.
For example, the following template snippet has been used in the ``syncuser`` overlay
to generate a combined ``/etc/passwd``.
.. code-block::
{{
printf "%s\n%s"
(IncludeFrom $.ImageName "/etc/passwd" | trim)
(Include (printf "%s/%s" .Paths.Sysconfdir "passwd") | trim)
| UniqueField ":" 0 | trim
}}
Node specific files
-------------------