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 ["optional"] = "Optional software (excluding compilers)", 83 ["optional-jail"] = "Optional software (excluding compilers)", 84 ["base"] = "The complete base system (includes devel and optional)", 85 ["base-jail"] = "The complete base system (includes devel and optional)", 86 ["src"] = "System source tree", 87 ["tests"] = "Test suite", 88 ["lib32"] = "32-bit compatibility libraries", 89 ["debug"] = "Debug symbols for the selected components", 90 } 91 92 -- These defaults match what the non-pkgbase installer selects 93 -- by default. 94 local defaults = { 95 ["base"] = "on", 96 ["base-jail"] = "on", 97 ["kernel-dbg"] = "on", 98 } 99 -- Enable compat sets by default. 100 for compat in all_libcompats:gmatch("%S+") do 101 defaults["lib" .. compat] = "on" 102 end 103 104 -- Sorting the components is necessary to ensure that the ordering is 105 -- consistent in the UI. 106 local sorted_components = {} 107 108 -- Determine which components we want to offer the user. 109 local show_component = function (component) 110 -- "pkg" is always installed if present. 111 if component == "pkg" then return false end 112 113 -- Don't include individual "-dbg" components, because those 114 -- are handled via the "debug" component, except for kernel-dbg 115 -- which is always shown for non-jail installations. 116 if component == "kernel-dbg" then 117 return (not options.jail) 118 end 119 if component:match("%-dbg$") then return false end 120 121 -- Some sets have "-jail" variants which are jail-specific 122 -- variants of the base set. 123 124 if options.jail and components[component.."-jail"] then 125 -- If we're installing in a jail, and this component 126 -- has a jail variant, hide it. 127 return false 128 end 129 130 if not options.jail and component:match("%-jail$") then 131 -- Otherwise if we're not installing in a jail, and 132 -- this is a jail variant, hide it. 133 return false 134 end 135 136 -- "minimal(-jail)" is always installed if present. 137 if component == "minimal" or component == "minimal-jail" then 138 return false 139 end 140 141 -- "kernel" (the generic kernel) and "kernels" (the set) are 142 -- never offered; we always install the kernel for a non-jail 143 -- installation. 144 if component == "kernel" or component == "kernels" then 145 return false 146 end 147 148 -- If we didn't find a reason to hide this component, show it. 149 return true 150 end 151 152 for component, _ in pairs(components) do 153 if show_component(component) then 154 table.insert(sorted_components, component) 155 end 156 end 157 158 table.sort(sorted_components) 159 160 local checklist_items = {} 161 for _, component in ipairs(sorted_components) do 162 local description = descriptions[component] or "" 163 local default = defaults[component] or "off" 164 table.insert(checklist_items, component) 165 table.insert(checklist_items, description) 166 table.insert(checklist_items, default) 167 end 168 169 local bsddialog_args = { 170 "--backtitle", "FreeBSD Installer", 171 "--title", "Select System Components", 172 "--nocancel", 173 "--disable-esc", 174 "--separate-output", 175 "--checklist", 176 "A minimal set of packages suitable for a multi-user system ".. 177 "is always installed. Select additional packages you wish ".. 178 "to install:", 179 "0", "0", "0", -- autosize 180 } 181 append_list(bsddialog_args, checklist_items) 182 183 local exit_code, output = bsddialog(bsddialog_args) 184 -- This should only be possible if bsddialog is killed by a signal 185 -- or buggy, we disable the cancel option and esc key. 186 -- If this does happen, there's not much we can do except exit with a 187 -- hopefully useful stack trace. 188 assert(exit_code == 0) 189 190 -- Always install the minimal set, since it's required for the system 191 -- to work. The base set depends on minimal, but it's fine to install 192 -- both, and this way the user can remove the base set without pkg 193 -- autoremove then trying to remove minimal. 194 local selected = {} 195 if options.jail then 196 table.insert(selected, "minimal-jail") 197 else 198 table.insert(selected, "minimal") 199 end 200 201 -- If pkg is available, always install it so the user can manage the 202 -- installed system. This is optional, because a repository built 203 -- from src alone won't have a pkg package. 204 if components["pkg"] then 205 table.insert(selected, "pkg") 206 end 207 208 if not options.jail then 209 table.insert(selected, "kernel") 210 end 211 212 for component in output:gmatch("[^\n]+") do 213 table.insert(selected, component) 214 end 215 216 return selected 217end 218 219-- Returns a list of pkgbase packages selected by the user 220local function select_packages(pkg, options) 221 -- These are the components which aren't generated automatically from 222 -- package sets. 223 local components = { 224 ["kernel"] = {}, 225 ["kernel-dbg"] = {}, 226 ["debug"] = {}, 227 } 228 229 -- Note: if you update this list, you must also update the list in 230 -- release/scripts/pkgbase-stage.lua. 231 local kernel_packages = { 232 -- Most architectures use this 233 ["FreeBSD-kernel-generic"] = true, 234 -- PowerPC uses either of these, depending on platform 235 ["FreeBSD-kernel-generic64"] = true, 236 ["FreeBSD-kernel-generic64le"] = true, 237 } 238 239 local rquery = capture(pkg .. "rquery -U -r FreeBSD-base %n") 240 for package in rquery:gmatch("[^\n]+") do 241 local setname = package:match("^FreeBSD%-set%-(.+)$") 242 243 if setname then 244 components[setname] = components[setname] or {} 245 table.insert(components[setname], package) 246 elseif kernel_packages[package] then 247 table.insert(components["kernel"], package) 248 elseif kernel_packages[package:match("(.*)%-dbg$")] then 249 table.insert(components["kernel-dbg"], package) 250 elseif package == "pkg" then 251 components["pkg"] = components["pkg"] or {} 252 table.insert(components["pkg"], package) 253 end 254 end 255 256 -- Assert that both a kernel and the "minimal" set are available, since 257 -- those are both required to install a functional system. Don't worry 258 -- if other sets are missing (e.g. base or src), which might happen 259 -- when using custom install media. 260 assert(#components["kernel"] == 1) 261 assert(#components["minimal"] == 1) 262 263 -- Prompt the user for what to install. 264 local selected = select_components(components, options) 265 266 -- Determine if the "debug" component was selected. 267 local debug = false 268 for _, component in ipairs(selected) do 269 if component == "debug" then 270 debug = true 271 break 272 end 273 end 274 275 local packages = {} 276 for _, component in ipairs(selected) do 277 local pkglist = components[component] 278 append_list(packages, pkglist) 279 280 -- If the debug component was selected, install the -dbg 281 -- package for each set. We have to check if the dbg set 282 -- actually exists, because some sets (src, tests) don't 283 -- have a -dbg subpackage. 284 for _, c in ipairs(pkglist) do 285 local setname = c:match("^FreeBSD%-set%-(.*)$") 286 if debug and setname then 287 local dbgset = setname.."-dbg" 288 if components[dbgset] then 289 append_list(packages, components[dbgset]) 290 end 291 end 292 end 293 end 294 295 return packages 296end 297 298local function parse_options() 299 local options = {} 300 for _, a in ipairs(arg) do 301 if a == "--jail" then 302 options.jail = true 303 else 304 io.stderr:write("Error: unknown option " .. a .. "\n") 305 os.exit(1) 306 end 307 end 308 return options 309end 310 311-- Fetch and install pkgbase packages to BSDINSTALL_CHROOT. 312-- Respect BSDINSTALL_PKG_REPOS_DIR if set, otherwise use pkg.freebsd.org. 313local function pkgbase() 314 local options = parse_options() 315 316 -- TODO Support fully offline pkgbase installation by taking a new enough 317 -- version of pkg.pkg as input. 318 if not os.execute("pkg -N > /dev/null 2>&1") then 319 print("Bootstrapping pkg on the host system") 320 assert(os.execute("pkg bootstrap -y")) 321 end 322 323 local chroot = assert(os.getenv("BSDINSTALL_CHROOT")) 324 assert(os.execute("mkdir -p " .. chroot)) 325 326 -- Always install the default FreeBSD-base.conf file to the chroot, even 327 -- if we don't actually fetch the packages from the repository specified 328 -- there (e.g. because we are performing an offline installation). 329 local chroot_repos_dir = chroot .. "/usr/local/etc/pkg/repos/" 330 assert(os.execute("mkdir -p " .. chroot_repos_dir)) 331 assert(os.execute("cp /usr/share/bsdinstall/FreeBSD-base.conf " .. 332 chroot_repos_dir)) 333 334 local repos_dir = os.getenv("BSDINSTALL_PKG_REPOS_DIR") 335 if not repos_dir then 336 repos_dir = chroot_repos_dir 337 -- Since pkg always interprets fingerprints paths as relative to 338 -- the --rootdir we must copy the key from the host. 339 assert(os.execute("mkdir -p " .. chroot .. "/usr/share/keys")) 340 assert(os.execute("cp -R /usr/share/keys/pkg " .. chroot .. "/usr/share/keys/")) 341 end 342 343 -- We must use --repo-conf-dir rather than -o REPOS_DIR here as the latter 344 -- is interpreted relative to the --rootdir. BSDINSTALL_PKG_REPOS_DIR must 345 -- be allowed to point to a path outside the chroot. 346 local pkg = "pkg --rootdir " .. chroot .. 347 " --repo-conf-dir " .. repos_dir .. " -o IGNORE_OSVERSION=yes " 348 349 while not os.execute(pkg .. "update") do 350 if not prompt_yn("Updating repositories failed, try again?") then 351 os.exit(1) 352 end 353 end 354 355 local packages = table.concat(select_packages(pkg, options), " ") 356 357 while not os.execute(pkg .. "install -U -F -y -r FreeBSD-base " .. packages) do 358 if not prompt_yn("Fetching packages failed, try again?") then 359 os.exit(1) 360 end 361 end 362 363 if not os.execute(pkg .. "install -U -y -r FreeBSD-base " .. packages) then 364 os.exit(1) 365 end 366end 367 368pkgbase() 369