xref: /freebsd/usr.sbin/bsdinstall/scripts/pkgbase.in (revision 346be36e8861e26bfed44cbf960903d0055f6660)
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 = 0
184	local output = ""
185	if options.non_interactive then
186		local env_components = os.getenv("COMPONENTS")
187		if env_components then
188			output = env_components:gsub(" ", "\n")
189		else
190			output = "base\nkernel-dbg"
191		end
192	else
193		exit_code, output = bsddialog(bsddialog_args)
194	end
195	-- This should only be possible if bsddialog is killed by a signal
196	-- or buggy, we disable the cancel option and esc key.
197	-- If this does happen, there's not much we can do except exit with a
198	-- hopefully useful stack trace.
199	assert(exit_code == 0)
200
201	-- Always install the minimal set, since it's required for the system
202	-- to work.  The base set depends on minimal, but it's fine to install
203	-- both, and this way the user can remove the base set without pkg
204	-- autoremove then trying to remove minimal.
205	local selected = {}
206	if options.jail then
207		table.insert(selected, "minimal-jail")
208	else
209		table.insert(selected, "minimal")
210	end
211
212	-- If pkg is available, always install it so the user can manage the
213	-- installed system.  This is optional, because a repository built
214	-- from src alone won't have a pkg package.
215	if components["pkg"] then
216		table.insert(selected, "pkg")
217	end
218
219	if not options.jail then
220		table.insert(selected, "kernel")
221	end
222
223	for component in output:gmatch("[^\n]+") do
224		table.insert(selected, component)
225	end
226
227	return selected
228end
229
230-- Returns a list of pkgbase packages selected by the user
231local function select_packages(pkg, options)
232	-- These are the components which aren't generated automatically from
233	-- package sets.
234	local components = {
235		["kernel"] = {},
236		["kernel-dbg"] = {},
237		["debug"] = {},
238	}
239
240	-- Note: if you update this list, you must also update the list in
241	-- release/scripts/pkgbase-stage.lua.
242	local kernel_packages = {
243		-- Most architectures use this
244		["FreeBSD-kernel-generic"] = true,
245		-- PowerPC uses either of these, depending on platform
246		["FreeBSD-kernel-generic64"] = true,
247		["FreeBSD-kernel-generic64le"] = true,
248	}
249
250	local rquery = capture(pkg .. "rquery -U -r FreeBSD-base %n")
251	for package in rquery:gmatch("[^\n]+") do
252		local setname = package:match("^FreeBSD%-set%-(.+)$")
253
254		if setname then
255			components[setname] = components[setname] or {}
256			table.insert(components[setname], package)
257		elseif kernel_packages[package] then
258			table.insert(components["kernel"], package)
259		elseif kernel_packages[package:match("(.*)%-dbg$")] then
260			table.insert(components["kernel-dbg"], package)
261		elseif package == "pkg" then
262			components["pkg"] = components["pkg"] or {}
263			table.insert(components["pkg"], package)
264		end
265	end
266
267	-- Assert that both a kernel and the "minimal" set are available, since
268	-- those are both required to install a functional system.  Don't worry
269	-- if other sets are missing (e.g. base or src), which might happen
270	-- when using custom install media.
271	assert(#components["kernel"] == 1)
272	assert(#components["minimal"] == 1)
273
274	-- Prompt the user for what to install.
275	local selected = select_components(components, options)
276
277	-- Determine if the "debug" component was selected.
278	local debug = false
279	for _, component in ipairs(selected) do
280		if component == "debug" then
281			debug = true
282			break
283		end
284	end
285
286	local packages = {}
287	for _, component in ipairs(selected) do
288		local pkglist = components[component]
289		append_list(packages, pkglist)
290
291		-- If the debug component was selected, install the -dbg
292		-- package for each set.  We have to check if the dbg set
293		-- actually exists, because some sets (src, tests) don't
294		-- have a -dbg subpackage.
295		for _, c in ipairs(pkglist) do
296			local setname = c:match("^FreeBSD%-set%-(.*)$")
297			if debug and setname then
298				local dbgset = setname.."-dbg"
299				if components[dbgset] then
300					append_list(packages, components[dbgset])
301				end
302			end
303		end
304	end
305
306	return packages
307end
308
309local function parse_options()
310	local options = {}
311	for _, a in ipairs(arg) do
312		if a == "--jail" then
313			options.jail = true
314		elseif a == "--non-interactive" then
315			options.non_interactive = true
316		else
317			io.stderr:write("Error: unknown option " .. a .. "\n")
318			os.exit(1)
319		end
320	end
321	return options
322end
323
324-- Fetch and install pkgbase packages to BSDINSTALL_CHROOT.
325-- Respect BSDINSTALL_PKG_REPOS_DIR if set, otherwise use pkgbase.freebsd.org.
326local function pkgbase()
327	local options = parse_options()
328
329	-- TODO Support fully offline pkgbase installation by taking a new enough
330	-- version of pkg.pkg as input.
331	if not os.execute("pkg -N > /dev/null 2>&1") then
332		print("Bootstrapping pkg on the host system")
333		assert(os.execute("pkg bootstrap -y"))
334	end
335
336	local chroot = assert(os.getenv("BSDINSTALL_CHROOT"))
337	assert(os.execute("mkdir -p " .. chroot))
338
339	local repos_dir = os.getenv("BSDINSTALL_PKG_REPOS_DIR")
340	if not repos_dir then
341		repos_dir = "/usr/share/bsdinstall/"
342		-- Since pkg always interprets fingerprints paths as relative to
343		-- the --rootdir we must copy the key from the host.
344		assert(os.execute("mkdir -p " .. chroot .. "/usr/share/keys"))
345		assert(os.execute("cp -R /usr/share/keys/* " .. chroot .. "/usr/share/keys/"))
346	end
347
348	-- We must use --repo-conf-dir rather than -o REPOS_DIR here as the latter
349	-- is interpreted relative to the --rootdir. BSDINSTALL_PKG_REPOS_DIR must
350	-- be allowed to point to a path outside the chroot.
351	local pkg = "pkg --rootdir " .. chroot ..
352		" --repo-conf-dir " .. repos_dir .. " -o IGNORE_OSVERSION=yes "
353
354	while not os.execute(pkg .. "update") do
355		if not prompt_yn("Updating repositories failed, try again?") then
356			os.exit(1)
357		end
358	end
359
360	local packages = table.concat(select_packages(pkg, options), " ")
361
362	while not os.execute(pkg .. "install -U -F -y -r FreeBSD-base " .. packages) do
363		if not prompt_yn("Fetching packages failed, try again?") then
364			os.exit(1)
365		end
366	end
367
368	if not os.execute(pkg .. "install -U -y -r FreeBSD-base " .. packages) then
369		os.exit(1)
370	end
371
372	-- Enable the FreeBSD-base repository for this system.
373	assert(os.execute("mkdir -p " .. chroot .. "/usr/local/etc/pkg/repos"))
374	assert(os.execute("echo 'FreeBSD-base: { enabled: yes }' > " .. chroot .. "/usr/local/etc/pkg/repos/FreeBSD.conf"))
375end
376
377pkgbase()
378