git: ba5df7a2d03c - main - nuageinit: Improvements for nuageinit

From: Jesús Daniel Colmenares Oviedo <dtxdf_at_FreeBSD.org>
Date: Fri, 22 Aug 2025 18:43:52 UTC
The branch main has been updated by dtxdf:

URL: https://cgit.FreeBSD.org/src/commit/?id=ba5df7a2d03cd5624b1825ca8d4c39dcaace7796

commit ba5df7a2d03cd5624b1825ca8d4c39dcaace7796
Author:     Jesús Daniel Colmenares Oviedo <dtxdf@FreeBSD.org>
AuthorDate: 2025-08-22 18:14:18 +0000
Commit:     Jesús Daniel Colmenares Oviedo <dtxdf@FreeBSD.org>
CommitDate: 2025-08-22 18:40:36 +0000

    nuageinit: Improvements for nuageinit
    
    - Fix 'pkg update' usage:
      - The function 'nuage:run_pkg_cmd(...)' adds the flag '-y', which
        does not make sense with some commands such as 'pkg update',
        causing an error when updating the repository catalogs.
    - Fix typo 'ssh-authorized-keys -> ssh_authorized_keys' in
      'nuageinit(7)' man page.
    - Document 'ssh_authorized_keys' parameter.
    - Use device configuration ID when no 'match' rule is specified:
      - This is the default behavior of cloud-init when no match rule is
        specified, so the device is configured anyway (even if it does not
        exist). This greatly simplifies things, since in many cases
        'if_vtnet(4)' is used, so there is no need to perform a comparison
        with the MAC address.
    - Document 'network' parameter:
      - Add example to 'EXAMPLES' section.
    - Set 'gateway[46]' only when 'addresses' is specified:
      - To comply with the cloud-init specification, 'gateway4' and 'gateway6'
        must only take effect when 'addresses' (or static configuration) is
        specified.
    - Use a separate function to check 'match' rules:
      - This way, we can easily add new logic to new types of rules.
    - Implement 'network.ethernets.{id}.match.name' parameter:
      - But unlike cloud-init, which works with glob expressions (although it
        depends on the network backend), this implementation takes advantage
        of Lua pattern-matching expressions.
    
        Also note that previously we were only concerned with one interface
        matching, however, to be cloud-init-compliant, we need to configure
        the matching interfaces (one or more).
    - Set default router only once.
    - Implement 'network.ethernets.{id}.wakeonlan' parameter.
    - Implement 'network.ethernets.{id}.set-name' parameter.
    - Implement 'network.ethernets.{id}.match.driver' parameter:
      - Rename 'get_ifaces(...)' function as 'get_ifaces_by_mac(...)'.
      - Add get_ifaces_by_driver(...) function.
    - Implement 'network.ethernets.{id}.mtu' parameter.
    - Implement 'nameservers' parameter.
    - Use 'resolvconf(8)' to manipulate 'resolv.conf(5)'.
    - Use 'tzsetup(8)' to set time zone.
    
    Reviewed by:            bapt@
    Approved by:            bapt@
    Differential Revision:  https://reviews.freebsd.org/D51643
---
 libexec/nuageinit/nuage.lua             |  20 ++-
 libexec/nuageinit/nuageinit             | 238 ++++++++++++++++++++++++++++----
 libexec/nuageinit/nuageinit.7           |  92 +++++++++++-
 libexec/nuageinit/tests/Makefile        |   1 +
 libexec/nuageinit/tests/nuage.sh        |   9 ++
 libexec/nuageinit/tests/nuageinit.sh    |   4 +-
 libexec/nuageinit/tests/settimezone.lua |   5 +
 7 files changed, 335 insertions(+), 34 deletions(-)

diff --git a/libexec/nuageinit/nuage.lua b/libexec/nuageinit/nuage.lua
index 493ae11d6ca7..48f54b120615 100644
--- a/libexec/nuageinit/nuage.lua
+++ b/libexec/nuageinit/nuage.lua
@@ -451,6 +451,23 @@ local function chpasswd(obj)
 	end
 end
 
+local function settimezone(timezone)
+	if timezone == nil then
+		return
+	end
+	local root = os.getenv("NUAGE_FAKE_ROOTDIR")
+	if not root then
+		root = "/"
+	end
+
+	f, _, rc = os.execute("tzsetup -s -C " .. root .. " " .. timezone)
+
+	if not f then
+		warnmsg("Impossible to configure time zone ( rc = " .. rc .. " )")
+		return
+	end
+end
+
 local function pkg_bootstrap()
 	if os.getenv("NUAGE_RUN_TESTS") then
 		return true
@@ -480,7 +497,7 @@ local function install_package(package)
 end
 
 local function run_pkg_cmd(subcmd)
-	local cmd = "pkg " .. subcmd .. " -y"
+	local cmd = "env ASSUME_ALWAYS_YES=yes pkg " .. subcmd
 	if os.getenv("NUAGE_RUN_TESTS") then
 		print(cmd)
 		return true
@@ -556,6 +573,7 @@ local n = {
 	dirname = dirname,
 	mkdir_p = mkdir_p,
 	sethostname = sethostname,
+	settimezone = settimezone,
 	adduser = adduser,
 	addgroup = addgroup,
 	addsshkey = addsshkey,
diff --git a/libexec/nuageinit/nuageinit b/libexec/nuageinit/nuageinit
index 0fcdc7274db3..70b27cb33d87 100755
--- a/libexec/nuageinit/nuageinit
+++ b/libexec/nuageinit/nuageinit
@@ -46,7 +46,15 @@ local function open_config(name)
 	return openat("/etc/rc.conf.d", name)
 end
 
-local function get_ifaces()
+local function open_resolv_conf()
+	return openat("/etc", "resolv.conf")
+end
+
+local function open_resolvconf_conf()
+	return openat("/etc", "resolvconf.conf")
+end
+
+local function get_ifaces_by_mac()
 	local parser = ucl.parser()
 	-- grab ifaces
 	local ns = io.popen("netstat -i --libxo json")
@@ -77,6 +85,10 @@ local function sethostname(obj)
 	end
 end
 
+local function settimezone(obj)
+	nuage.settimezone(obj.timezone)
+end
+
 local function groups(obj)
 	if obj.groups == nil then return end
 
@@ -171,6 +183,59 @@ local function ssh_authorized_keys(obj)
 	end
 end
 
+local function nameservers(interface, obj)
+	local resolvconf_conf_handler = open_resolvconf_conf()
+
+	if obj.search then
+		local with_space = false
+
+		resolvconf_conf_handler:write('search_domains="')
+
+		for _, d in ipairs(obj.search) do
+			if with_space then
+				resolvconf_conf_handler:write(" " .. d)
+			else
+				resolvconf_conf_handler:write(d)
+				with_space = true
+			end
+		end
+
+		resolvconf_conf_handler:write('"\n')
+	end
+
+	if obj.addresses then
+		local with_space = false
+
+		resolvconf_conf_handler:write('name_servers="')
+
+		for _, a in ipairs(obj.addresses) do
+			if with_space then
+				resolvconf_conf_handler:write(" " .. a)
+			else
+				resolvconf_conf_handler:write(a)
+				with_space = true
+			end
+		end
+
+		resolvconf_conf_handler:write('"\n')
+	end
+
+	resolvconf_conf_handler:close()
+
+	local resolv_conf = root .. "/etc/resolv.conf"
+
+	resolv_conf_attr = lfs.attributes(resolv_conf)
+
+	if resolv_conf_attr == nil then
+		resolv_conf_handler = open_resolv_conf()
+		resolv_conf_handler:close()
+	end
+
+	if not os.execute("resolvconf -a " .. interface .. " < " .. resolv_conf) then
+		nuage.warn("Failed to execute resolvconf(8)")
+	end
+end
+
 local function install_packages(packages)
 	if not nuage.pkg_bootstrap() then
 		nuage.warn("Failed to bootstrap pkg, skip installing packages")
@@ -187,6 +252,85 @@ local function install_packages(packages)
 	end
 end
 
+local function list_ifaces()
+	local proc = io.popen("ifconfig -l")
+	local raw_ifaces = proc:read("*a")
+	proc:close()
+	local ifaces = {}
+	for i in raw_ifaces:gmatch("[^%s]+") do
+		table.insert(ifaces, i)
+	end
+	return ifaces
+end
+
+local function get_ifaces_by_driver()
+	local proc = io.popen("ifconfig -D")
+	local drivers = {}
+	local last_interface = nil
+	for line in proc:lines() do
+	    local interface = line:match("^([%S]+): ")
+
+	    if interface then
+		last_interface = interface
+	    end
+
+	    local driver = line:match("^[%s]+drivername: ([%S]+)$")
+
+	    if driver then
+		drivers[driver] = last_interface
+	    end
+	end
+	proc:close()
+
+	return drivers
+end
+
+local function match_rules(rules)
+	-- To comply with the cloud-init specification, all rules must match and a table
+	-- with the matching interfaces must be returned. This changes the way we initially
+	-- thought about our implementation, since at first we only needed one interface,
+	-- but cloud-init performs actions on a group of matching interfaces.
+	local interfaces = {}
+	if rules.macaddress then
+		local ifaces = get_ifaces_by_mac()
+		local interface = ifaces[rules.macaddress]
+		if not interface then
+			nuage.warn("not interface matching by MAC address: " .. rules.macaddress)
+			return
+		end
+		interfaces[interface] = 1
+	end
+	if rules.name then
+		local match = false
+		for _, i in pairs(list_ifaces()) do
+			if i:match(rules.name) then
+				match = true
+				interfaces[i] = 1
+			end
+		end
+		if not match then
+			nuage.warn("not interface matching by name: " .. rules.name)
+			return
+		end
+	end
+	if rules.driver then
+		local match = false
+		local drivers = get_ifaces_by_driver()
+		for d in pairs(drivers) do
+			if d:match(rules.driver) then
+				match = true
+				interface = drivers[d]
+				interfaces[interface] = 1
+			end
+		end
+		if not match then
+			nuage.warn("not interface matching by driver: " .. rules.driver)
+			return
+		end
+	end
+	return interfaces
+end
+
 local function write_files(files, defer)
 	if not files then
 		return
@@ -210,41 +354,76 @@ end
 local function network_config(obj)
 	if obj.network == nil then return end
 
-	local ifaces = get_ifaces()
 	local network = open_config("network")
 	local routing = open_config("routing")
 	local ipv6 = {}
-	for _, v in pairs(obj.network.ethernets) do
-		if not v.match then
-			goto next
+	local set_defaultrouter = true
+	local set_defaultrouter6 = true
+	local set_nameservers = true
+	for i, v in pairs(obj.network.ethernets) do
+		local interfaces = {}
+		if v.match then
+			interfaces = match_rules(v.match)
+
+			if next(interfaces) == nil then
+				goto next
+			end
+		else
+			interfaces[i] = 1
 		end
-		if not v.match.macaddress then
-			goto next
+		local extra_opts = ""
+		if v.wakeonlan then
+			extra_opts = extra_opts .. " wol"
 		end
-		if not ifaces[v.match.macaddress] then
-			nuage.warn("not interface matching: " .. v.match.macaddress)
-			goto next
+		if v.mtu then
+			if type(v.mtu) == "number" then
+				mtu = tostring(v.mtu)
+			else
+				mtu = v.mtu
+			end
+			if mtu:match("%d") then
+				extra_opts = extra_opts .. " mtu " .. mtu
+			else
+				nuage.warn("MTU is not set because the specified value is invalid: " .. mtu)
+			end
 		end
-		local interface = ifaces[v.match.macaddress]
-		if v.dhcp4 then
-			network:write("ifconfig_" .. interface .. '="DHCP"\n')
-		elseif v.addresses then
-			for _, a in pairs(v.addresses) do
-				if a:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)") then
-					network:write("ifconfig_" .. interface .. '="inet ' .. a .. '"\n')
-				else
-					network:write("ifconfig_" .. interface .. '_ipv6="inet6 ' .. a .. '"\n')
-					ipv6[#ipv6 + 1] = interface
+		for interface in pairs(interfaces) do
+			if v.match and v.match.macaddress and v["set-name"] then
+				local ifaces = get_ifaces_by_mac()
+				local matched = ifaces[v.match.macaddress]
+				if matched and matched == interface then
+					network:write("ifconfig_" .. interface .. '_name=' .. v["set-name"] .. '\n')
+					interface = v["set-name"]
+				end
+			end
+			if v.dhcp4 then
+				network:write("ifconfig_" .. interface .. '="DHCP"' .. extra_opts .. '\n')
+			elseif v.addresses then
+				for _, a in pairs(v.addresses) do
+					if a:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)") then
+						network:write("ifconfig_" .. interface .. '="inet ' .. a .. extra_opts .. '"\n')
+					else
+						network:write("ifconfig_" .. interface .. '_ipv6="inet6 ' .. a .. extra_opts .. '"\n')
+						ipv6[#ipv6 + 1] = interface
+					end
+				end
+				if set_nameservers and v.nameservers then
+					set_nameservers = false
+					nameservers(interface, v.nameservers)
+				end
+				if set_defaultrouter and v.gateway4 then
+					set_defaultrouter = false
+					routing:write('defaultrouter="' .. v.gateway4 .. '"\n')
+				end
+				if v.gateway6 then
+					if set_defaultrouter6 then
+						set_defaultrouter6 = false
+						routing:write('ipv6_defaultrouter="' .. v.gateway6 .. '"\n')
+					end
+					routing:write("ipv6_route_" .. interface .. '="' .. v.gateway6)
+					routing:write(" -prefixlen 128 -interface " .. interface .. '"\n')
 				end
 			end
-		end
-		if v.gateway4 then
-			routing:write('defaultrouter="' .. v.gateway4 .. '"\n')
-		end
-		if v.gateway6 then
-			routing:write('ipv6_defaultrouter="' .. v.gateway6 .. '"\n')
-			routing:write("ipv6_route_" .. interface .. '="' .. v.gateway6)
-			routing:write(" -prefixlen 128 -interface " .. interface .. '"\n')
 		end
 		::next::
 	end
@@ -316,7 +495,7 @@ local function config2_network(p)
 	end
 	local obj = parser:get_object()
 
-	local ifaces = get_ifaces()
+	local ifaces = get_ifaces_by_mac()
 	if not ifaces then
 		nuage.warn("no network interfaces found")
 		return
@@ -468,6 +647,7 @@ f:close()
 if line == "#cloud-config" then
 	local pre_network_calls = {
 		sethostname,
+		settimezone,
 		groups,
 		create_default_user,
 		ssh_keys,
diff --git a/libexec/nuageinit/nuageinit.7 b/libexec/nuageinit/nuageinit.7
index 327ce160e151..604d8a2221ca 100644
--- a/libexec/nuageinit/nuageinit.7
+++ b/libexec/nuageinit/nuageinit.7
@@ -143,6 +143,11 @@ Specify a fully qualified domain name for the instance.
 Specify the hostname of the instance if
 .Qq Ic fqdn
 is not set.
+.It Ic timezone
+Sets the system timezone based on the value provided.
+.Pp
+See also
+.Xr tzfile 3 Ns .
 .It Ic groups
 An array of strings or objects to be created:
 .Bl -bullet
@@ -176,6 +181,81 @@ boolean which determines the value of the
 configuration in
 .Pa /etc/ssh/sshd_config
 .It Ic network
+Network configuration parameters.
+.Bl -tag -width "ethernets"
+.It Ic ethernets
+Mapping representing a generic configuration for existing network interfaces.
+.Pp
+Each key is an interface name that is only used when no
+.Sy match
+rule is specified.
+If
+.Sy match
+rules are specified, an arbitrary name can be used
+.Po e.g.: id0 Pc Ns .
+.Bl -tag -width "nameservers"
+.It Ic match
+This selects a subset of available physical devices by various hardware properties.
+The following configuration will then apply to all matching devices, as soon as
+they appear. All specified properties must match. The following properties for
+creating matches are supported:
+.Bl -tag -width "macaddress"
+.It Ic macaddress
+.No Device's MAC address in the form Sy xx:xx:xx:xx:xx:xx Ns .
+Letters should be lowercase.
+.It Ic name
+Current interface name. Lua pattern-matching expressions are supported.
+.It Ic driver
+Interface driver name and unit number of the interface. Lua pattern-natching expressions
+are supported.
+.El
+.It Ic set-name
+When matching on unique properties such as MAC, match rules can be written so that they
+match only one device. Then this property can be used to give that device a more
+specific/desirable/nicer name than the default.
+.Pp
+While multiple properties can be used in a match,
+.Sy macaddress
+is required for nuageinit to perform the rename.
+.It Ic mtu
+The MTU key represents a device's Maximum Transmission Unit, the largest size packet
+or frame.
+.It Ic wakeonlan
+Enable wake on LAN. Off by default.
+.It Ic dhcp4
+Configure the interface to use DHCP.
+.Pp
+This takes precedence over
+.Sy addresses
+when both are specified.
+.It Ic addresses
+List of strings representing IPv4 or IPv6 addresses.
+.It Ic gateway4
+Set default gateway for IPv4, for manual address configuration. This requires setting
+.Sy addresses
+too.
+.Pp
+Since only one default router can be configured at a time, this parameter is applied
+when processing the first entry, and any others are silently ignored.
+.It Ic gateway6
+Set default gateway for IPv6, for manual address configuration. This requires setting
+.Sy addresses
+too.
+.Pp
+Since only one default router can be configured at a time, this parameter is applied
+when processing the first entry, and any others are silently ignored.
+.It Ic nameservers
+Set DNS servers and search domains, for manual address configuration.
+.Pp
+There are two supported fields:
+.Bl -tag -width "addresses"
+.It Ic search
+Search list for host-name lookup.
+.It Ic addresses
+List of IPv4 or IPv6 name server addresses that the resolver should query.
+.El
+.El
+.El
 .It Ic runcmd
 An array of commands to be run at the end of the boot process
 .It Ic packages
@@ -186,7 +266,7 @@ Update the remote package metadata.
 Upgrade the packages installed to their latest version.
 .It Ic users
 Specify a list of users to be created:
-.Bl -tag -width "plain_text_passwd"
+.Bl -tag -width "ssh_authorized_keys"
 .It Ic name
 Name of the user.
 .It Ic gecos
@@ -201,6 +281,8 @@ The list of other groups the user should belong to.
 A boolean which determines if the home directory should be created or not.
 .It Ic shell
 The shell that should be used for the user.
+.It Ic ssh_authorized_keys
+List of SSH keys for the user.
 .It Ic passwd
 The encrypted password for the user.
 .It Ic plain_text_passwd
@@ -287,7 +369,7 @@ users:
   - name: user
     gecos: Foo B. Bar
     sudo: ALL=(ALL) NOPASSWD:ALL
-    ssh-authorized-keys:
+    ssh_authorized_keys:
       - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAr...
 packages:
   - neovim
@@ -303,6 +385,12 @@ ssh_keys:
     ...
     -----END OPENSSH PRIVATE KEY-----
   ed25519_public: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK+MH4E8KO32N5CXRvXVqvyZVl0+6ue4DobdhU0FqFd+
+network:
+  ethernets:
+    vtnet0:
+      addresses:
+        - 192.168.8.2/24
+      gateway4: 192.168.8.1
 .Ed
 .Sh SEE ALSO
 .Xr kenv 2 ,
diff --git a/libexec/nuageinit/tests/Makefile b/libexec/nuageinit/tests/Makefile
index c69bc28a4c86..dc8997717b59 100644
--- a/libexec/nuageinit/tests/Makefile
+++ b/libexec/nuageinit/tests/Makefile
@@ -15,6 +15,7 @@ ${PACKAGE}FILES+=	adduser_passwd.lua
 ${PACKAGE}FILES+=	dirname.lua
 ${PACKAGE}FILES+=	err.lua
 ${PACKAGE}FILES+=	sethostname.lua
+${PACKAGE}FILES+=	settimezone.lua
 ${PACKAGE}FILES+=	warn.lua
 ${PACKAGE}FILES+=	addfile.lua
 
diff --git a/libexec/nuageinit/tests/nuage.sh b/libexec/nuageinit/tests/nuage.sh
index 56651c8c5bb7..b709d25532ff 100644
--- a/libexec/nuageinit/tests/nuage.sh
+++ b/libexec/nuageinit/tests/nuage.sh
@@ -7,12 +7,21 @@
 export NUAGE_FAKE_ROOTDIR="$PWD"
 
 atf_test_case sethostname
+atf_test_case settimezone
 atf_test_case addsshkey
 atf_test_case adduser
 atf_test_case adduser_passwd
 atf_test_case addgroup
 atf_test_case addfile
 
+settimezone_body()
+{
+	atf_check /usr/libexec/flua $(atf_get_srcdir)/settimezone.lua
+	if [ ! -f etc/localtime ]; then
+		atf_fail "localtime not written"
+	fi
+}
+
 sethostname_body()
 {
 	atf_check /usr/libexec/flua $(atf_get_srcdir)/sethostname.lua
diff --git a/libexec/nuageinit/tests/nuageinit.sh b/libexec/nuageinit/tests/nuageinit.sh
index 849f1c258b62..98593f7d75b0 100644
--- a/libexec/nuageinit/tests/nuageinit.sh
+++ b/libexec/nuageinit/tests/nuageinit.sh
@@ -815,7 +815,7 @@ config2_userdata_update_packages_body()
 package_update: true
 EOF
 	chmod 755 "${PWD}"/media/nuageinit/user_data
-	atf_check -o inline:"pkg update -y\n" /usr/libexec/nuageinit "${PWD}"/media/nuageinit postnet
+	atf_check -o inline:"env ASSUME_ALWAYS_YES=yes pkg update\n" /usr/libexec/nuageinit "${PWD}"/media/nuageinit postnet
 }
 
 config2_userdata_upgrade_packages_body()
@@ -829,7 +829,7 @@ config2_userdata_upgrade_packages_body()
 package_upgrade: true
 EOF
 	chmod 755 "${PWD}"/media/nuageinit/user_data
-	atf_check -o inline:"pkg upgrade -y\n" /usr/libexec/nuageinit "${PWD}"/media/nuageinit postnet
+	atf_check -o inline:"env ASSUME_ALWAYS_YES=yes pkg upgrade\n" /usr/libexec/nuageinit "${PWD}"/media/nuageinit postnet
 }
 
 config2_userdata_shebang_body()
diff --git a/libexec/nuageinit/tests/settimezone.lua b/libexec/nuageinit/tests/settimezone.lua
new file mode 100644
index 000000000000..a8cacf09f4e7
--- /dev/null
+++ b/libexec/nuageinit/tests/settimezone.lua
@@ -0,0 +1,5 @@
+#!/usr/libexec/flua
+
+local n = require("nuage")
+
+n.settimezone("UTC")