nuageinit: implement write_files

write_files is a list of files that should be created at the first boot

each file content can be either plain text or encoded in base64 (note
that cloudinit specify that gzip is supported, but we do not support it
yet.)

All other specifier from cloudinit should work:
by default all files will juste overwrite exesiting files except if
"append" is set to true, permissions, ownership can be specified.
The files are create before packages are being installed and user
created.

if "defer" is set to true then the file is being created after packages
installation and package manupulation.

This feature is requested for KDE's CI.
This commit is contained in:
Baptiste Daroussin 2025-06-26 13:32:07 +02:00
parent 442472e1b7
commit 19a7ea3cc4
7 changed files with 264 additions and 6 deletions

View file

@ -7,6 +7,39 @@ local unistd = require("posix.unistd")
local sys_stat = require("posix.sys.stat")
local lfs = require("lfs")
local function decode_base64(input)
local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
input = string.gsub(input, '[^'..b..'=]', '')
local result = {}
local bits = ''
-- convert all characters in bits
for i = 1, #input do
local x = input:sub(i, i)
if x == '=' then
break
end
local f = b:find(x) - 1
for j = 6, 1, -1 do
bits = bits .. (f % 2^j - f % 2^(j-1) > 0 and '1' or '0')
end
end
for i = 1, #bits, 8 do
local byte = bits:sub(i, i + 7)
if #byte == 8 then
local c = 0
for j = 1, 8 do
c = c + (byte:sub(j, j) == '1' and 2^(8 - j) or 0)
end
table.insert(result, string.char(c))
end
end
return table.concat(result)
end
local function warnmsg(str, prepend)
if not str then
return
@ -441,6 +474,58 @@ local function upgrade_packages()
return run_pkg_cmd("upgrade")
end
local function addfile(file, defer)
if type(file) ~= "table" then
return false, "Invalid object"
end
if defer and not file.defer then
return true
end
if not defer and file.defer then
return true
end
if not file.path then
return false, "No path provided for the file to write"
end
local content = nil
if file.content then
if file.encoding then
if file.encoding == "b64" or file.encoding == "base64" then
content = decode_base64(file.content)
else
return false, "Unsupported encoding: " .. file.encoding
end
else
content = file.content
end
end
local mode = "w"
if file.append then
mode = "a"
end
local root = os.getenv("NUAGE_FAKE_ROOTDIR")
if not root then
root = ""
end
local filepath = root .. file.path
local f = assert(io.open(filepath, mode))
if content then
f:write(content)
end
f:close()
if file.permissions then
-- convert from octal to decimal
local perm = tonumber(file.permissions, 8)
sys_stat.chmod(file.path, perm)
end
if file.owner then
local owner, group = string.match(file.owner, "([^:]+):([^:]+)")
unistd.chown(file.path, owner, group)
end
return true
end
local n = {
warn = warnmsg,
err = errmsg,
@ -456,7 +541,8 @@ local n = {
install_package = install_package,
update_packages = update_packages,
upgrade_packages = upgrade_packages,
addsudo = addsudo
addsudo = addsudo,
addfile = addfile
}
return n

View file

@ -188,6 +188,25 @@ local function install_packages(packages)
end
end
local function write_files(files, defer)
if not files then
return
end
for n, file in pairs(files) do
local r, errstr = nuage.addfile(file, defer)
if not r then
nuage.warn("Skipping write_files entry number " .. n .. ": " .. errstr)
end
end
end
local function write_files_not_defered(obj)
write_files(obj.write_files, false)
end
local function write_files_defered(obj)
write_files(obj.write_files, true)
end
-- Set network configuration from user_data
local function network_config(obj)
if obj.network == nil then return end
@ -456,13 +475,15 @@ if line == "#cloud-config" then
ssh_authorized_keys,
network_config,
ssh_pwauth,
runcmd
runcmd,
write_files_not_defered,
}
local post_network_calls = {
packages,
users,
chpasswd
chpasswd,
write_files_defered,
}
f = io.open(ni_path .. "/" .. ud)

View file

@ -2,7 +2,7 @@
.\"
.\" Copyright (c) 2025 Baptiste Daroussin <bapt@FreeBSD.org>
.\"
.Dd June 16, 2025
.Dd June 26, 2025
.Dt NUAGEINIT 7
.Os
.Sh NAME
@ -239,6 +239,42 @@ where x is a number, then the password is considered encrypted,
otherwise the password is considered plaintext.
.El
.El
.It Ic write_files
An array of objects representing files to be created at first boot.
The files are being created before the installation of any packages
and the creation of the users.
The only mandatory field is:
.Ic path .
It accepts the following keys for each objects:
.Bl -tag -width "permissions"
.It Ic content
The content to be written to the file.
If this key is not existing then an empty file will be created.
.It Ic encoding
Specifiy the encoding used for content.
If not specified, then plain text is considered.
Only
.Ar b64
and
.Ar base64
are supported for now.
.It Ic path
The path of the file to be created.
.Pq Note intermerdiary directories will not be created .
.It Ic permissions
A string representing the permission of the file in octal.
.It Ic owner
A string representing the owner, two forms are possible:
.Ar user
or
.Ar user:group .
.It Ic append
A boolean to specify the content should be appended to the file if the file
exists.
.It Ic defer
A boolean to specify that the files should be created after the packages are
installed and the users are created.
.El
.El
.Sh EXAMPLES
Here is an example of a YAML configuration for

View file

@ -16,5 +16,6 @@ ${PACKAGE}FILES+= dirname.lua
${PACKAGE}FILES+= err.lua
${PACKAGE}FILES+= sethostname.lua
${PACKAGE}FILES+= warn.lua
${PACKAGE}FILES+= addfile.lua
.include <bsd.test.mk>

View file

@ -0,0 +1,71 @@
#!/bin/libexec/flua
local n = require("nuage")
local lfs = require("lfs")
local f = {
content = "plop"
}
local r, err = n.addfile(f, false)
if r or err ~= "No path provided for the file to write" then
n.err("addfile should not accept a file to write without a path")
end
local function addfile_and_getres(file)
local r, err = n.addfile(file, false)
if not r then
n.err(err)
end
local root = os.getenv("NUAGE_FAKE_ROOTDIR")
if not root then
root = ""
end
local filepath = root .. file.path
local resf = assert(io.open(filepath, "r"))
local str = resf:read("*all")
resf:close()
return str
end
-- simple file
f.path="/tmp/testnuage"
local str = addfile_and_getres(f)
if str ~= f.content then
n.err("Invalid file content")
end
-- the file is overwriten
f.content = "test"
str = addfile_and_getres(f)
if str ~= f.content then
n.err("Invalid file content, not overwritten")
end
-- try to append now
f.content = "more"
f.append = true
str = addfile_and_getres(f)
if str ~= "test" .. f.content then
n.err("Invalid file content, not appended")
end
-- base64
f.content = "YmxhCg=="
f.encoding = "base64"
f.append = false
str = addfile_and_getres(f)
if str ~= "bla\n" then
n.err("Invalid file content, base64 decode")
end
-- b64
f.encoding = "b64"
str = addfile_and_getres(f)
if str ~= "bla\n" then
n.err("Invalid file content, b64 decode")
print("==>" .. str .. "<==")
end

View file

@ -1,5 +1,5 @@
#-
# Copyright (c) 2022 Baptiste Daroussin <bapt@FreeBSD.org>
# Copyright (c) 2022-2025 Baptiste Daroussin <bapt@FreeBSD.org>
#
# SPDX-License-Identifier: BSD-2-Clause
#
@ -11,6 +11,7 @@ atf_test_case addsshkey
atf_test_case adduser
atf_test_case adduser_passwd
atf_test_case addgroup
atf_test_case addfile
sethostname_body()
{
@ -73,6 +74,12 @@ addgroup_body()
atf_check -o inline:"impossible_groupname:*:1001:\n" grep impossible_groupname etc/group
}
addfile_body()
{
mkdir tmp
atf_check /usr/libexec/flua $(atf_get_srcdir)/addfile.lua
}
atf_init_test_cases()
{
atf_add_test_case sethostname
@ -80,4 +87,5 @@ atf_init_test_cases()
atf_add_test_case adduser
atf_add_test_case adduser_passwd
atf_add_test_case addgroup
atf_add_test_case addfile
}

View file

@ -1,5 +1,5 @@
#-
# Copyright (c) 2022 Baptiste Daroussin <bapt@FreeBSD.org>
# Copyright (c) 2022-2025 Baptiste Daroussin <bapt@FreeBSD.org>
#
# SPDX-License-Identifier: BSD-2-Clause
#
@ -29,6 +29,7 @@ atf_test_case config2_userdata_update_packages
atf_test_case config2_userdata_upgrade_packages
atf_test_case config2_userdata_shebang
atf_test_case config2_userdata_fqdn_and_hostname
atf_test_case config2_userdata_write_files
setup_test_adduser()
{
@ -847,6 +848,39 @@ EOF
fi
}
config2_userdata_write_files_body()
{
mkdir -p media/nuageinit
setup_test_adduser
printf "{}" > media/nuageinit/meta_data.json
cat > media/nuageinit/user_data <<EOF
#cloud-config
write_files:
- content: "plop"
path: /file1
- path: /emptyfile
- content: !!binary |
YmxhCg==
path: /file_base64
encoding: b64
permissions: '0755'
owner: nobody
- content: "bob"
path: "/foo"
defer: true
EOF
atf_check -o empty /usr/libexec/nuageinit "${PWD}"/media/nuageinit config-2
atf_check -o inline:"plop" cat file1
atf_check -o inline:"" cat emptyfile
atf_check -o inline:"bla\n" cat file_base64
test -f foo && atf_fail "foo creation should have been defered"
atf_check -o match:"^-rwxr-xr-x.*nobody" ls -l file_base64
rm file1 emptyfile file_base64
atf_check -o empty /usr/libexec/nuageinit "${PWD}"/media/nuageinit postnet
test -f file1 -o -f emptyfile -o -f file_base64 && atf_fail "defer not working properly"
atf_check -o inline:"bob" cat foo
}
config2_userdata_fqdn_and_hostname_body()
{
mkdir -p media/nuageinit
@ -892,4 +926,5 @@ atf_init_test_cases()
atf_add_test_case config2_userdata_upgrade_packages
atf_add_test_case config2_userdata_shebang
atf_add_test_case config2_userdata_fqdn_and_hostname
atf_add_test_case config2_userdata_write_files
}