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

Revision cb98b4924a135850a05f45695d9a54acf8b4925b

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