dch

dch / bump

Last active 1 day ago

Like 0

a script to upgrade remote FreeBSD systems. - zfs only - base package only - UEFI boot only

bump-freebsd-manually Raw
1#!/usr/libexec/flua
2local USAGE = [[
3Patch or upgrade one pkgbase host by hand:
4- create rollback boot environment,
5- do pkg upgrades.
6
7Usage:
8 ssh your-server \
9 'doas /usr/libexec/flua - -f -r -u 15.1' \
10 < ./tools/scripts/bump-freebsd-manually
11
12Usage: bump-freebsd-manually [-n] [-b] [-r] [-f] [-u X.Y]
13 -n report only: no boot environment, pkg upgrade -n
14 (pkg itself is still upgraded, so the report is accurate)
15 -b base system only, leave other packages alone
16 -r reboot when done (default: leave the reboot to the operator)
17 -f official FreeBSD repos only (FreeBSD-base, FreeBSD-ports),
18 ignoring rxv_devops
19 -u X.Y release upgrade to X.Y (e.g. 15.1), same major only; a
20 -RELEASE or -pN suffix is ignored. Without it the host is
21 patched on the release it already runs
22
23The host OS is always upgraded from FreeBSD-base, and packages always
24from your pkg repo, unless -f is given, in which case from FreeBSD-ports.
25]]
26
27local BASE_REPO = "FreeBSD-base"
28local PORTS_REPO = "pkg"
29local OFFICIAL_PORTS_REPO = "FreeBSD-ports"
30local BE_PREFIX = "patch"
31local MIN_PKG = "2.8.4"
32local EFI_LOADER = "/boot/loader.efi"
33local EFI_BOOT_DIR = "/boot/efi/efi/boot"
34
35local dry, base_only, reboot, official_only, target = false, false, false, false, nil
36
37local function usage(code)
38 io.stderr:write(USAGE)
39 os.exit(code)
40end
41
42local function die(msg)
43 io.stderr:write(msg, "\n")
44 os.exit(1)
45end
46
47-- Single-quotes a value for safe interpolation into a shell command
48-- string, since os.execute/io.popen hand the whole line to /bin/sh.
49local function shquote(s)
50 return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
51end
52
53local function run(cmd)
54 io.write("+ ", cmd, "\n")
55 local ok = os.execute(cmd)
56 if not ok then die("command failed: " .. cmd) end
57end
58
59-- Like run(), for "pkg upgrade -n": pkg exits non-zero both when there
60-- is work to do and when something is wrong (repo unreachable or
61-- disabled, broken dependency), so the exit status alone cannot tell
62-- the two apart. Only a non-zero exit after pkg got as far as printing
63-- its job summary counts as success.
64local function run_dry(cmd)
65 io.write("+ ", cmd, "\n")
66 local p = io.popen(cmd .. " 2>&1", "r")
67 if not p then die("failed to run: " .. cmd) end
68 local summary = false
69 for line in p:lines() do
70 io.write(line, "\n")
71 if line:find("^Number of packages to be ") then summary = true end
72 end
73 if not p:close() and not summary then die("command failed: " .. cmd) end
74end
75
76-- Every caller treats this like shell command substitution: $(cmd)
77-- under "set -eu" does NOT abort on the inner command's exit status,
78-- only the io.popen() call itself (the shell failing to start) does.
79local function capture(cmd)
80 local p = io.popen(cmd, "r")
81 if not p then die("failed to run: " .. cmd) end
82 local out = (p:read("*a") or ""):gsub("%s+$", "")
83 p:close()
84 return out
85end
86
87local function file_exists(path)
88 return os.execute("test -f " .. shquote(path)) and true or false
89end
90
91-- Last bectl entry (by creation order) whose name starts with prefix
92-- and has no active/mounted marker, i.e. "tail -1" over awk's matches.
93local function find_existing_be(prefix)
94 local match
95 for line in capture("bectl list -H"):gmatch("[^\n]+") do
96 local name, active = line:match("^([^\t]*)\t([^\t]*)")
97 if active == "-" and name and name:sub(1, #prefix) == prefix then
98 match = name
99 end
100 end
101 return match
102end
103
104-- Hand-rolled getopt: -n -b -r are flags, -u takes an argument
105-- (attached or as the next word), and short flags may be combined
106-- (e.g. -nb), same as the shell version's getopts.
107do
108 local i = 1
109 while i <= #arg do
110 local a = arg[i]
111 if a == "--" then
112 i = i + 1
113 break
114 elseif a:match("^%-.") then
115 local j = 2
116 while j <= #a do
117 local c = a:sub(j, j)
118 if c == "n" then dry = true
119 elseif c == "b" then base_only = true
120 elseif c == "r" then reboot = true
121 elseif c == "f" then official_only = true
122 elseif c == "u" then
123 local val = a:sub(j + 1)
124 if val == "" then
125 i = i + 1
126 val = arg[i]
127 if not val then usage(2) end
128 end
129 target = val
130 j = #a
131 else
132 usage(2)
133 end
134 j = j + 1
135 end
136 else
137 break
138 end
139 i = i + 1
140 end
141end
142
143if official_only then PORTS_REPO = OFFICIAL_PORTS_REPO end
144
145if capture("id -u") ~= "0" then die("run as root (doas " .. arg[0] .. " ...)") end
146if not capture("pkg which -q /usr/bin/uname"):find("FreeBSD%-runtime") then
147 die("not a pkgbase host")
148end
149
150local installed
151do
152 local f = io.open("/etc/os-release", "r")
153 if f then
154 for line in f:lines() do
155 local v = line:match('^VERSION_ID="([^"]*)"')
156 if v then installed = v; break end
157 end
158 f:close()
159 end
160end
161local running = capture("freebsd-version -r"):gsub("%-.*", "")
162if not installed or installed == "" then
163 die("cannot read VERSION_ID from /etc/os-release")
164end
165
166if target then
167 -- 15.1-RELEASE and 15.1-p3 both mean 15.1, as in the patch_freebsd role
168 local given = target
169 target = target:gsub("%-.*", "")
170 if not target:match("^%d+%.%d+$") then
171 die("-u expects a release like 15.1, got " .. given)
172 end
173 local target_major = target:match("^(%d+)%.")
174 local installed_major = installed:match("^(%d+)%.")
175 local running_major = running:match("^(%d+)%.")
176 if not (target_major == installed_major and target_major == running_major) then
177 die("only minor upgrades within the same major are supported")
178 end
179 if capture("pkg version -t " .. shquote(target) .. " " .. shquote(installed)) == "<" then
180 die("target " .. target .. " is older than installed " .. installed)
181 end
182 print("=== Upgrading " .. installed .. " (running " .. running .. ") -> " .. target .. " ===")
183else
184 -- a patch would reboot onto the new kernel but skip the EFI loader
185 -- and package steps, leaving the upgrade half done
186 if installed ~= running then
187 die(installed .. " installed but " .. running .. " running: finish with -u " .. installed)
188 end
189 print("=== Patching " .. installed .. " ===")
190end
191run("freebsd-version -kru")
192
193-- One rollback point per day is enough: reuse a BE made earlier today.
194local be
195if not dry then
196 local today = os.date("!%Y%m%d")
197 be = find_existing_be(BE_PREFIX .. "-" .. today .. "-")
198 if be then
199 print("rollback boot environment already exists: " .. be)
200 else
201 be = BE_PREFIX .. "-" .. os.date("!%Y%m%d-%H%M")
202 run("bectl create " .. shquote(be))
203 end
204end
205
206-- pkg first, on its own, so everything below uses the pkg that does the
207-- work. This happens even with -n: the release upgrade needs MIN_PKG,
208-- and a report from an older pkg would not show what a real run does.
209run("pkg upgrade -y -r " .. shquote(PORTS_REPO) .. " pkg")
210
211-- PKG is interpolated unquoted into the command below, word-split on
212-- purpose: the release upgrade adds options to it. The -n/-y flag has
213-- to precede any package pattern.
214local PKG = "pkg"
215local function upgrade(args)
216 local cmd = PKG .. " upgrade " .. (dry and "-n" or "-y") .. " " .. args
217 if dry then run_dry(cmd) else run(cmd) end
218end
219
220if not target then
221 upgrade("-r " .. shquote(BASE_REPO) .. " -g 'FreeBSD-*'")
222 if not base_only then upgrade("-r " .. shquote(PORTS_REPO)) end
223else
224 local pkg_static_v = capture("pkg-static -v")
225 if capture("pkg version -t " .. shquote(pkg_static_v) .. " " .. shquote(MIN_PKG)) == "<" then
226 die("pkg " .. pkg_static_v .. " is older than " .. MIN_PKG .. ", which a release upgrade requires")
227 end
228 -- the repo URLs expand ${VERSION_MINOR}, so overriding it points pkg
229 -- at the target release's packages
230 local minor = target:match("%.(%d+)$")
231 -- The target release's packages are newer than the running system,
232 -- which pkg refuses ("wrong OS version") unless told to ignore it.
233 PKG = "pkg-static -o VERSION_MINOR=" .. minor .. " -o IGNORE_OSVERSION=yes"
234 upgrade("-r " .. shquote(BASE_REPO))
235
236 -- The firmware boots the ESP copy of the loader, which pkg does not
237 -- touch. Keep the previous one as lastgood.efi.
238 local arch = capture("uname -m")
239 local efi_file
240 if arch == "amd64" then efi_file = "bootx64.efi"
241 elseif arch == "arm64" then efi_file = "bootaa64.efi"
242 else die("unknown architecture " .. arch) end
243 local efi_boot = EFI_BOOT_DIR .. "/" .. efi_file
244
245 if not dry then
246 if not (file_exists(EFI_LOADER) and file_exists(efi_boot)) then
247 die("missing " .. EFI_LOADER .. " or " .. efi_boot ..
248 " (is the ESP mounted on /boot/efi?). The base upgrade has been applied but the host has not been rebooted.")
249 end
250 if capture("sha256 -q " .. shquote(EFI_LOADER)) ~= capture("sha256 -q " .. shquote(efi_boot)) then
251 run("mv " .. shquote(efi_boot) .. " " .. shquote(EFI_BOOT_DIR .. "/lastgood.efi"))
252 run("cp -p " .. shquote(EFI_LOADER) .. " " .. shquote(efi_boot))
253 end
254 end
255
256 if not base_only then upgrade("-r " .. shquote(PORTS_REPO)) end
257end
258
259if dry then os.exit(0) end
260
261print("=== Done ===")
262run("freebsd-version -kru")
263print("rollback: bectl activate " .. be .. " && shutdown -r now")
264if reboot then
265 run('shutdown -r now "Rebooting after FreeBSD pkgbase upgrade"')
266else
267 print("reboot to finish: shutdown -r now")
268end
269