xref: /freebsd/usr.sbin/bsdinstall/scripts/pkgbase.in (revision d9cc3d558d00ee7f62dbef2032f099033c91d2a1)
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.
107		--
108		-- "-dbg" sets are never offered, because those are handled
109		-- via the "debug" component.
110		--
111		-- "kernels" is never offered because we only want one kernel,
112		-- which is handled separately.
113		--
114		-- Sets whose name ends in "-jail" are intended for jails, and
115		-- are only offered if no_kernel is set.
116		if not component:match("^minimal") and
117		   not component:match("%-dbg$") and
118		   not (component == "kernels") and
119		   not (not options.no_kernel and component:match("%-jail$")) then
120			table.insert(sorted_components, component)
121		end
122	end
123	table.sort(sorted_components)
124
125	local checklist_items = {}
126	for _, component in ipairs(sorted_components) do
127		if component ~= "kernel" and not
128		    (component == "kernel-dbg" and options.no_kernel) then
129			local description = descriptions[component] or ""
130			local default = defaults[component] or "off"
131			table.insert(checklist_items, component)
132			table.insert(checklist_items, description)
133			table.insert(checklist_items, default)
134		end
135	end
136
137	local bsddialog_args = {
138		"--backtitle", "FreeBSD Installer",
139		"--title", "Select System Components",
140		"--nocancel",
141		"--disable-esc",
142		"--separate-output",
143		"--checklist",
144		"A minimal set of packages suitable for a multi-user system "..
145		"is always installed.  Select additional packages you wish "..
146		"to install:",
147		"0", "0", "0", -- autosize
148	}
149	append_list(bsddialog_args, checklist_items)
150
151	local exit_code, output = bsddialog(bsddialog_args)
152	-- This should only be possible if bsddialog is killed by a signal
153	-- or buggy, we disable the cancel option and esc key.
154	-- If this does happen, there's not much we can do except exit with a
155	-- hopefully useful stack trace.
156	assert(exit_code == 0)
157
158	-- Always install the minimal set, since it's required for the system
159	-- to work.  The base set depends on minimal, but it's fine to install
160	-- both, and this way the user can remove the base set without pkg
161	-- autoremove then trying to remove minimal.
162	local selected = {"minimal"}
163
164	if not options.no_kernel then
165		table.insert(selected, "kernel")
166	end
167
168	for component in output:gmatch("[^\n]+") do
169		table.insert(selected, component)
170	end
171
172	return selected
173end
174
175-- Returns a list of pkgbase packages selected by the user
176local function select_packages(pkg, options)
177	-- These are the components which aren't generated automatically from
178	-- package sets.
179	local components = {
180		["kernel"] = {},
181		["kernel-dbg"] = {},
182		["debug"] = {},
183	}
184
185	local rquery = capture(pkg .. "rquery -U -r FreeBSD-base %n")
186	for package in rquery:gmatch("[^\n]+") do
187		local setname = package:match("^FreeBSD%-set%-(.+)$")
188
189		if setname then
190			components[setname] = components[setname] or {}
191			table.insert(components[setname], package)
192		elseif package:match("^FreeBSD%-kernel%-.*") and
193			package ~= "FreeBSD-kernel-man"
194		then
195			-- Kernels other than FreeBSD-kernel-generic are ignored
196			if package == "FreeBSD-kernel-generic" then
197				table.insert(components["kernel"], package)
198			elseif package == "FreeBSD-kernel-generic-dbg" then
199				table.insert(components["kernel-dbg"], package)
200			end
201		end
202	end
203
204	-- Assert that both a kernel and the "minimal" set are available, since
205	-- those are both required to install a functional system.  Don't worry
206	-- if other sets are missing (e.g. base or src), which might happen
207	-- when using custom install media.
208	assert(#components["kernel"] == 1)
209	assert(#components["minimal"] == 1)
210
211	-- Prompt the user for what to install.
212	local selected = select_components(components, options)
213
214	-- Determine if the "debug" component was selected.
215	local debug = false
216	for _, component in ipairs(selected) do
217		if component == "debug" then
218			debug = true
219			break
220		end
221	end
222
223	local packages = {}
224	for _, component in ipairs(selected) do
225		local pkglist = components[component]
226		append_list(packages, pkglist)
227
228		-- If the debug component was selected, install the -dbg
229		-- package for each set.  We have to check if the dbg set
230		-- actually exists, because some sets (src, tests) don't
231		-- have a -dbg subpackage.
232		for _, c in ipairs(pkglist) do
233			local setname = c:match("^FreeBSD%-set%-(.*)$")
234			if debug and setname then
235				local dbgset = setname.."-dbg"
236				if components[dbgset] then
237					append_list(packages, components[dbgset])
238				end
239			end
240		end
241	end
242
243	return packages
244end
245
246local function parse_options()
247	local options = {}
248	for _, a in ipairs(arg) do
249		if a == "--no-kernel" then
250			options.no_kernel = true
251		else
252			io.stderr:write("Error: unknown option " .. a .. "\n")
253			os.exit(1)
254		end
255	end
256	return options
257end
258
259-- Fetch and install pkgbase packages to BSDINSTALL_CHROOT.
260-- Respect BSDINSTALL_PKG_REPOS_DIR if set, otherwise use pkg.freebsd.org.
261local function pkgbase()
262	local options = parse_options()
263
264	-- TODO Support fully offline pkgbase installation by taking a new enough
265	-- version of pkg.pkg as input.
266	if not os.execute("pkg -N > /dev/null 2>&1") then
267		print("Bootstrapping pkg on the host system")
268		assert(os.execute("pkg bootstrap -y"))
269	end
270
271	local chroot = assert(os.getenv("BSDINSTALL_CHROOT"))
272	assert(os.execute("mkdir -p " .. chroot))
273
274	-- Always install the default FreeBSD-base.conf file to the chroot, even
275	-- if we don't actually fetch the packages from the repository specified
276	-- there (e.g. because we are performing an offline installation).
277	local chroot_repos_dir = chroot .. "/usr/local/etc/pkg/repos/"
278	assert(os.execute("mkdir -p " .. chroot_repos_dir))
279	assert(os.execute("cp /usr/share/bsdinstall/FreeBSD-base.conf " ..
280		chroot_repos_dir))
281
282	local repos_dir = os.getenv("BSDINSTALL_PKG_REPOS_DIR")
283	if not repos_dir then
284		repos_dir = chroot_repos_dir
285		-- Since pkg always interprets fingerprints paths as relative to
286		-- the --rootdir we must copy the key from the host.
287		assert(os.execute("mkdir -p " .. chroot .. "/usr/share/keys"))
288		assert(os.execute("cp -R /usr/share/keys/pkg " .. chroot .. "/usr/share/keys/"))
289	end
290
291	-- We must use --repo-conf-dir rather than -o REPOS_DIR here as the latter
292	-- is interpreted relative to the --rootdir. BSDINSTALL_PKG_REPOS_DIR must
293	-- be allowed to point to a path outside the chroot.
294	local pkg = "pkg --rootdir " .. chroot ..
295		" --repo-conf-dir " .. repos_dir .. " -o IGNORE_OSVERSION=yes "
296
297	while not os.execute(pkg .. "update") do
298		if not prompt_yn("Updating repositories failed, try again?") then
299			os.exit(1)
300		end
301	end
302
303	local packages = table.concat(select_packages(pkg, options), " ")
304
305	while not os.execute(pkg .. "install -U -F -y -r FreeBSD-base " .. packages) do
306		if not prompt_yn("Fetching packages failed, try again?") then
307			os.exit(1)
308		end
309	end
310
311	if not os.execute(pkg .. "install -U -y -r FreeBSD-base " .. packages) then
312		os.exit(1)
313	end
314end
315
316pkgbase()
317