Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/freebsd-src
Path: blob/main/usr.sbin/bsdinstall/scripts/pkgbase.in
101998 views
#!/usr/libexec/flua

-- SPDX-License-Identifier: BSD-2-Clause
--
-- Copyright(c) 2025 The FreeBSD Foundation.
--
-- This software was developed by Isaac Freund <[email protected]>
-- under sponsorship from the FreeBSD Foundation.

local sys_wait = require("posix.sys.wait")
local unistd = require("posix.unistd")

local all_libcompats <const> = "%%_ALL_libcompats%%"

-- Run a command using the OS shell and capture the stdout
-- Strips exactly one trailing newline if present, does not strip any other whitespace.
-- Asserts that the command exits cleanly
local function capture(command)
	local p = io.popen(command)
	local output = p:read("*a")
	assert(p:close())
	-- Strip exactly one trailing newline from the output, if there is one
	return output:match("(.-)\n$") or output
end

local function append_list(list, other)
	for _, item in ipairs(other) do
		table.insert(list, item)
	end
end

-- Read from the given fd until EOF
-- Returns all the data read as a single string
local function read_all(fd)
	local ret = ""
	repeat
		local buffer = assert(unistd.read(fd, 1024))
		ret = ret .. buffer
	until buffer == ""
	return ret
end

-- Run bsddialog with the given argument list
-- Returns the exit code and stderr output of bsddialog
local function bsddialog(args)
	local r, w = assert(unistd.pipe())

	local pid = assert(unistd.fork())
	if pid == 0 then
		assert(unistd.close(r))
		assert(unistd.dup2(w, 2))
		assert(unistd.execp("bsddialog", args))
		unistd._exit()
	end
	assert(unistd.close(w))

	local output = read_all(r)
	assert(unistd.close(r))

	local _, _, exit_code = assert(sys_wait.wait(pid))
	return exit_code, output
end

-- Prompts the user for a yes/no answer to the given question using bsddialog
-- Returns true if the user answers yes and false if the user answers no.
local function prompt_yn(question)
	local exit_code = bsddialog({
		"--yesno",
		"--disable-esc",
		question,
		0, 0, -- autosize
	})
	return exit_code == 0
end

-- Creates a dialog for component selection mirroring the
-- traditional tarball component selection dialog.
local function select_components(components, options)
	local descriptions = {
		["kernel-dbg"] = "Debug symbols for the kernel",
		["devel"] = "C/C++ compilers and related utilities",
		["optional"] = "Optional software (excluding compilers)",
		["optional-jail"] = "Optional software (excluding compilers)",
		["base"] = "The complete base system (includes devel and optional)",
		["base-jail"] = "The complete base system (includes devel and optional)",
		["src"] = "System source tree",
		["tests"] = "Test suite",
		["lib32"] = "32-bit compatibility libraries",
		["debug"] = "Debug symbols for the selected components",
	}

	-- These defaults match what the non-pkgbase installer selects
	-- by default.
	local defaults = {
		["base"] = "on",
		["base-jail"] = "on",
		["kernel-dbg"] = "on",
	}
	-- Enable compat sets by default.
	for compat in all_libcompats:gmatch("%S+") do
		defaults["lib" .. compat] = "on"
	end

	-- Sorting the components is necessary to ensure that the ordering is
	-- consistent in the UI.
	local sorted_components = {}

	-- Determine which components we want to offer the user.
	local show_component = function (component)
		-- "pkg" is always installed if present.
		if component == "pkg" then return false end

		-- Don't include individual "-dbg" components, because those
		-- are handled via the "debug" component, except for kernel-dbg
		-- which is always shown for non-jail installations.
		if component == "kernel-dbg" then
			return (not options.jail)
		end
		if component:match("%-dbg$") then return false end

		-- Some sets have "-jail" variants which are jail-specific
		-- variants of the base set.

		if options.jail and components[component.."-jail"] then
			-- If we're installing in a jail, and this component
			-- has a jail variant, hide it.
			return false
		end

		if not options.jail and component:match("%-jail$") then
			-- Otherwise if we're not installing in a jail, and
			-- this is a jail variant, hide it.
			return false
		end

		-- "minimal(-jail)" is always installed if present.
		if component == "minimal" or component == "minimal-jail" then
			return false
		end

		-- "kernel" (the generic kernel) and "kernels" (the set) are
		-- never offered; we always install the kernel for a non-jail
		-- installation.
		if component == "kernel" or component == "kernels" then
			return false
		end

		-- If we didn't find a reason to hide this component, show it.
		return true
	end

	for component, _ in pairs(components) do
		if show_component(component) then
			table.insert(sorted_components, component)
		end
	end

	table.sort(sorted_components)

	local checklist_items = {}
	for _, component in ipairs(sorted_components) do
		local description = descriptions[component] or ""
		local default = defaults[component] or "off"
		table.insert(checklist_items, component)
		table.insert(checklist_items, description)
		table.insert(checklist_items, default)
	end

	local bsddialog_args = {
		"--backtitle", "FreeBSD Installer",
		"--title", "Select System Components",
		"--nocancel",
		"--disable-esc",
		"--separate-output",
		"--checklist",
		"A minimal set of packages suitable for a multi-user system "..
		"is always installed.  Select additional packages you wish "..
		"to install:",
		"0", "0", "0", -- autosize
	}
	append_list(bsddialog_args, checklist_items)

	local exit_code, output = bsddialog(bsddialog_args)
	-- This should only be possible if bsddialog is killed by a signal
	-- or buggy, we disable the cancel option and esc key.
	-- If this does happen, there's not much we can do except exit with a
	-- hopefully useful stack trace.
	assert(exit_code == 0)

	-- Always install the minimal set, since it's required for the system
	-- to work.  The base set depends on minimal, but it's fine to install
	-- both, and this way the user can remove the base set without pkg
	-- autoremove then trying to remove minimal.
	local selected = {}
	if options.jail then
		table.insert(selected, "minimal-jail")
	else
		table.insert(selected, "minimal")
	end

	-- If pkg is available, always install it so the user can manage the
	-- installed system.  This is optional, because a repository built
	-- from src alone won't have a pkg package.
	if components["pkg"] then
		table.insert(selected, "pkg")
	end

	if not options.jail then
		table.insert(selected, "kernel")
	end

	for component in output:gmatch("[^\n]+") do
		table.insert(selected, component)
	end

	return selected
end

-- Returns a list of pkgbase packages selected by the user
local function select_packages(pkg, options)
	-- These are the components which aren't generated automatically from
	-- package sets.
	local components = {
		["kernel"] = {},
		["kernel-dbg"] = {},
		["debug"] = {},
	}

	-- Note: if you update this list, you must also update the list in
	-- release/scripts/pkgbase-stage.lua.
	local kernel_packages = {
		-- Most architectures use this
		["FreeBSD-kernel-generic"] = true,
		-- PowerPC uses either of these, depending on platform
		["FreeBSD-kernel-generic64"] = true,
		["FreeBSD-kernel-generic64le"] = true,
	}

	local rquery = capture(pkg .. "rquery -U -r FreeBSD-base %n")
	for package in rquery:gmatch("[^\n]+") do
		local setname = package:match("^FreeBSD%-set%-(.+)$")

		if setname then
			components[setname] = components[setname] or {}
			table.insert(components[setname], package)
		elseif kernel_packages[package] then
			table.insert(components["kernel"], package)
		elseif kernel_packages[package:match("(.*)%-dbg$")] then
			table.insert(components["kernel-dbg"], package)
		elseif package == "pkg" then
			components["pkg"] = components["pkg"] or {}
			table.insert(components["pkg"], package)
		end
	end

	-- Assert that both a kernel and the "minimal" set are available, since
	-- those are both required to install a functional system.  Don't worry
	-- if other sets are missing (e.g. base or src), which might happen
	-- when using custom install media.
	assert(#components["kernel"] == 1)
	assert(#components["minimal"] == 1)

	-- Prompt the user for what to install.
	local selected = select_components(components, options)

	-- Determine if the "debug" component was selected.
	local debug = false
	for _, component in ipairs(selected) do
		if component == "debug" then
			debug = true
			break
		end
	end

	local packages = {}
	for _, component in ipairs(selected) do
		local pkglist = components[component]
		append_list(packages, pkglist)

		-- If the debug component was selected, install the -dbg
		-- package for each set.  We have to check if the dbg set
		-- actually exists, because some sets (src, tests) don't
		-- have a -dbg subpackage.
		for _, c in ipairs(pkglist) do
			local setname = c:match("^FreeBSD%-set%-(.*)$")
			if debug and setname then
				local dbgset = setname.."-dbg"
				if components[dbgset] then
					append_list(packages, components[dbgset])
				end
			end
		end
	end

	return packages
end

local function parse_options()
	local options = {}
	for _, a in ipairs(arg) do
		if a == "--jail" then
			options.jail = true
		else
			io.stderr:write("Error: unknown option " .. a .. "\n")
			os.exit(1)
		end
	end
	return options
end

-- Fetch and install pkgbase packages to BSDINSTALL_CHROOT.
-- Respect BSDINSTALL_PKG_REPOS_DIR if set, otherwise use pkgbase.freebsd.org.
local function pkgbase()
	local options = parse_options()

	-- TODO Support fully offline pkgbase installation by taking a new enough
	-- version of pkg.pkg as input.
	if not os.execute("pkg -N > /dev/null 2>&1") then
		print("Bootstrapping pkg on the host system")
		assert(os.execute("pkg bootstrap -y"))
	end

	local chroot = assert(os.getenv("BSDINSTALL_CHROOT"))
	assert(os.execute("mkdir -p " .. chroot))

	local repos_dir = os.getenv("BSDINSTALL_PKG_REPOS_DIR")
	if not repos_dir then
		repos_dir = "/usr/share/bsdinstall/"
		-- Since pkg always interprets fingerprints paths as relative to
		-- the --rootdir we must copy the key from the host.
		assert(os.execute("mkdir -p " .. chroot .. "/usr/share/keys"))
		assert(os.execute("cp -R /usr/share/keys/* " .. chroot .. "/usr/share/keys/"))
	end

	-- We must use --repo-conf-dir rather than -o REPOS_DIR here as the latter
	-- is interpreted relative to the --rootdir. BSDINSTALL_PKG_REPOS_DIR must
	-- be allowed to point to a path outside the chroot.
	local pkg = "pkg --rootdir " .. chroot ..
		" --repo-conf-dir " .. repos_dir .. " -o IGNORE_OSVERSION=yes "

	while not os.execute(pkg .. "update") do
		if not prompt_yn("Updating repositories failed, try again?") then
			os.exit(1)
		end
	end

	local packages = table.concat(select_packages(pkg, options), " ")

	while not os.execute(pkg .. "install -U -F -y -r FreeBSD-base " .. packages) do
		if not prompt_yn("Fetching packages failed, try again?") then
			os.exit(1)
		end
	end

	if not os.execute(pkg .. "install -U -y -r FreeBSD-base " .. packages) then
		os.exit(1)
	end

	-- Enable the FreeBSD-base repository for this system.
	assert(os.execute("mkdir -p " .. chroot .. "/usr/local/etc/pkg/repos"))
	assert(os.execute("echo 'FreeBSD-base: { enabled: yes }' > " .. chroot .. "/usr/local/etc/pkg/repos/FreeBSD.conf"))
end

pkgbase()