1#!/usr/libexec/flua 2 3-- SPDX-License-Identifier: BSD-2-Clause 4-- 5-- Copyright(c) 2025 The FreeBSD Foundation. 6-- 7-- This software was developed by Isaac Freund <ifreund@freebsdfoundation.org> 8-- under sponsorship from the FreeBSD Foundation. 9 10local sys_wait = require("posix.sys.wait") 11local unistd = require("posix.unistd") 12 13local all_libcompats <const> = "%%_ALL_libcompats%%" 14 15-- Run a command using the OS shell and capture the stdout 16-- Strips exactly one trailing newline if present, does not strip any other whitespace. 17-- Asserts that the command exits cleanly 18local function capture(command) 19 local p = io.popen(command) 20 local output = p:read("*a") 21 assert(p:close()) 22 -- Strip exactly one trailing newline from the output, if there is one 23 return output:match("(.-)\n$") or output 24end 25 26local function append_list(list, other) 27 for _, item in ipairs(other) do 28 table.insert(list, item) 29 end 30end 31 32-- Read from the given fd until EOF 33-- Returns all the data read as a single string 34local function read_all(fd) 35 local ret = "" 36 repeat 37 local buffer = assert(unistd.read(fd, 1024)) 38 ret = ret .. buffer 39 until buffer == "" 40 return ret 41end 42 43-- Run bsddialog with the given argument list 44-- Returns the exit code and stderr output of bsddialog 45local function bsddialog(args) 46 local r, w = assert(unistd.pipe()) 47 48 local pid = assert(unistd.fork()) 49 if pid == 0 then 50 assert(unistd.close(r)) 51 assert(unistd.dup2(w, 2)) 52 assert(unistd.execp("bsddialog", args)) 53 unistd._exit() 54 end 55 assert(unistd.close(w)) 56 57 local output = read_all(r) 58 assert(unistd.close(r)) 59 60 local _, _, exit_code = assert(sys_wait.wait(pid)) 61 return exit_code, output 62end 63 64-- Prompts the user for a yes/no answer to the given question using bsddialog 65-- Returns true if the user answers yes and false if the user answers no. 66local function prompt_yn(question) 67 local exit_code = bsddialog({ 68 "--yesno", 69 "--disable-esc", 70 question, 71 0, 0, -- autosize 72 }) 73 return exit_code == 0 74end 75 76-- Creates a dialog for component selection mirroring the 77-- traditional tarball component selection dialog. 78local function select_components(components, options) 79 local descriptions = { 80 ["kernel-dbg"] = "Debug symbols for the kernel", 81 ["devel"] = "C/C++ compilers and related utilities", 82 ["base"] = "The complete base system (includes devel)", 83 ["src"] = "System source tree", 84 ["tests"] = "Test suite", 85 ["lib32"] = "32-bit compatibility libraries", 86 ["debug"] = "Debug symbols for the selected components", 87 } 88 89 -- These defaults match what the non-pkgbase installer selects 90 -- by default. 91 local defaults = { 92 ["base"] = "on", 93 ["kernel-dbg"] = "on", 94 } 95 -- Enable compat sets by default. 96 for compat in all_libcompats:gmatch("%S+") do 97 defaults["lib" .. compat] = "on" 98 end 99 100 -- Sorting the components is necessary to ensure that the ordering is 101 -- consistent in the UI. 102 local sorted_components = {} 103 for component, _ in pairs(components) do 104 -- Decide which sets we want to offer to the user: 105 -- 106 -- "minimal" is not offered since it's always included, as is 107 -- "pkg" if it's present. 108 -- 109 -- "-dbg" sets are never offered, because those are handled 110 -- via the "debug" component. 111 -- 112 -- "kernels" is never offered because we only want one kernel, 113 -- which is handled separately. 114 -- 115 -- Sets whose name ends in "-jail" are intended for jails, and 116 -- are only offered if no_kernel is set. 117 if component ~= "pkg" and 118 not component:match("^minimal") and 119 not component:match("%-dbg$") and 120 not (component == "kernels") and 121 not (not options.no_kernel and component:match("%-jail$")) then 122 table.insert(sorted_components, component) 123 end 124 end 125 table.sort(sorted_components) 126 127 local checklist_items = {} 128 for _, component in ipairs(sorted_components) do 129 if component ~= "kernel" and not 130 (component == "kernel-dbg" and options.no_kernel) then 131 local description = descriptions[component] or "" 132 local default = defaults[component] or "off" 133 table.insert(checklist_items, component) 134 table.insert(checklist_items, description) 135 table.insert(checklist_items, default) 136 end 137 end 138 139 local bsddialog_args = { 140 "--backtitle", "FreeBSD Installer", 141 "--title", "Select System Components", 142 "--nocancel", 143 "--disable-esc", 144 "--separate-output", 145 "--checklist", 146 "A minimal set of packages suitable for a multi-user system ".. 147 "is always installed. Select additional packages you wish ".. 148 "to install:", 149 "0", "0", "0", -- autosize 150 } 151 append_list(bsddialog_args, checklist_items) 152 153 local exit_code, output = bsddialog(bsddialog_args) 154 -- This should only be possible if bsddialog is killed by a signal 155 -- or buggy, we disable the cancel option and esc key. 156 -- If this does happen, there's not much we can do except exit with a 157 -- hopefully useful stack trace. 158 assert(exit_code == 0) 159 160 -- Always install the minimal set, since it's required for the system 161 -- to work. The base set depends on minimal, but it's fine to install 162 -- both, and this way the user can remove the base set without pkg 163 -- autoremove then trying to remove minimal. 164 local selected = {"minimal"} 165 166 -- If pkg is available, always install it so the user can manage the 167 -- installed system. This is optional, because a repository built 168 -- from src alone won't have a pkg package. 169 if components["pkg"] then 170 table.insert(selected, "pkg") 171 end 172 173 if not options.no_kernel then 174 table.insert(selected, "kernel") 175 end 176 177 for component in output:gmatch("[^\n]+") do 178 table.insert(selected, component) 179 end 180 181 return selected 182end 183 184-- Returns a list of pkgbase packages selected by the user 185local function select_packages(pkg, options) 186 -- These are the components which aren't generated automatically from 187 -- package sets. 188 local components = { 189 ["kernel"] = {}, 190 ["kernel-dbg"] = {}, 191 ["debug"] = {}, 192 } 193 194 -- Note: if you update this list, you must also update the list in 195 -- release/scripts/pkgbase-stage.lua. 196 local kernel_packages = { 197 -- Most architectures use this 198 ["FreeBSD-kernel-generic"] = true, 199 -- PowerPC uses either of these, depending on platform 200 ["FreeBSD-kernel-generic64"] = true, 201 ["FreeBSD-kernel-generic64le"] = true, 202 } 203 204 local rquery = capture(pkg .. "rquery -U -r FreeBSD-base %n") 205 for package in rquery:gmatch("[^\n]+") do 206 local setname = package:match("^FreeBSD%-set%-(.+)$") 207 208 if setname then 209 components[setname] = components[setname] or {} 210 table.insert(components[setname], package) 211 elseif kernel_packages[package] then 212 table.insert(components["kernel"], package) 213 elseif kernel_packages[package:match("(.*)%-dbg$")] then 214 table.insert(components["kernel-dbg"], package) 215 elseif package == "pkg" then 216 components["pkg"] = components["pkg"] or {} 217 table.insert(components["pkg"], package) 218 end 219 end 220 221 -- Assert that both a kernel and the "minimal" set are available, since 222 -- those are both required to install a functional system. Don't worry 223 -- if other sets are missing (e.g. base or src), which might happen 224 -- when using custom install media. 225 assert(#components["kernel"] == 1) 226 assert(#components["minimal"] == 1) 227 228 -- Prompt the user for what to install. 229 local selected = select_components(components, options) 230 231 -- Determine if the "debug" component was selected. 232 local debug = false 233 for _, component in ipairs(selected) do 234 if component == "debug" then 235 debug = true 236 break 237 end 238 end 239 240 local packages = {} 241 for _, component in ipairs(selected) do 242 local pkglist = components[component] 243 append_list(packages, pkglist) 244 245 -- If the debug component was selected, install the -dbg 246 -- package for each set. We have to check if the dbg set 247 -- actually exists, because some sets (src, tests) don't 248 -- have a -dbg subpackage. 249 for _, c in ipairs(pkglist) do 250 local setname = c:match("^FreeBSD%-set%-(.*)$") 251 if debug and setname then 252 local dbgset = setname.."-dbg" 253 if components[dbgset] then 254 append_list(packages, components[dbgset]) 255 end 256 end 257 end 258 end 259 260 return packages 261end 262 263local function parse_options() 264 local options = {} 265 for _, a in ipairs(arg) do 266 if a == "--no-kernel" then 267 options.no_kernel = true 268 else 269 io.stderr:write("Error: unknown option " .. a .. "\n") 270 os.exit(1) 271 end 272 end 273 return options 274end 275 276-- Fetch and install pkgbase packages to BSDINSTALL_CHROOT. 277-- Respect BSDINSTALL_PKG_REPOS_DIR if set, otherwise use pkg.freebsd.org. 278local function pkgbase() 279 local options = parse_options() 280 281 -- TODO Support fully offline pkgbase installation by taking a new enough 282 -- version of pkg.pkg as input. 283 if not os.execute("pkg -N > /dev/null 2>&1") then 284 print("Bootstrapping pkg on the host system") 285 assert(os.execute("pkg bootstrap -y")) 286 end 287 288 local chroot = assert(os.getenv("BSDINSTALL_CHROOT")) 289 assert(os.execute("mkdir -p " .. chroot)) 290 291 -- Always install the default FreeBSD-base.conf file to the chroot, even 292 -- if we don't actually fetch the packages from the repository specified 293 -- there (e.g. because we are performing an offline installation). 294 local chroot_repos_dir = chroot .. "/usr/local/etc/pkg/repos/" 295 assert(os.execute("mkdir -p " .. chroot_repos_dir)) 296 assert(os.execute("cp /usr/share/bsdinstall/FreeBSD-base.conf " .. 297 chroot_repos_dir)) 298 299 local repos_dir = os.getenv("BSDINSTALL_PKG_REPOS_DIR") 300 if not repos_dir then 301 repos_dir = chroot_repos_dir 302 -- Since pkg always interprets fingerprints paths as relative to 303 -- the --rootdir we must copy the key from the host. 304 assert(os.execute("mkdir -p " .. chroot .. "/usr/share/keys")) 305 assert(os.execute("cp -R /usr/share/keys/pkg " .. chroot .. "/usr/share/keys/")) 306 end 307 308 -- We must use --repo-conf-dir rather than -o REPOS_DIR here as the latter 309 -- is interpreted relative to the --rootdir. BSDINSTALL_PKG_REPOS_DIR must 310 -- be allowed to point to a path outside the chroot. 311 local pkg = "pkg --rootdir " .. chroot .. 312 " --repo-conf-dir " .. repos_dir .. " -o IGNORE_OSVERSION=yes " 313 314 while not os.execute(pkg .. "update") do 315 if not prompt_yn("Updating repositories failed, try again?") then 316 os.exit(1) 317 end 318 end 319 320 local packages = table.concat(select_packages(pkg, options), " ") 321 322 while not os.execute(pkg .. "install -U -F -y -r FreeBSD-base " .. packages) do 323 if not prompt_yn("Fetching packages failed, try again?") then 324 os.exit(1) 325 end 326 end 327 328 if not os.execute(pkg .. "install -U -y -r FreeBSD-base " .. packages) then 329 os.exit(1) 330 end 331end 332 333pkgbase() 334