| 1 | #!/usr/libexec/flua |
| 2 | local USAGE = [[ |
| 3 | Patch or upgrade one pkgbase host by hand: |
| 4 | - create rollback boot environment, |
| 5 | - do pkg upgrades. |
| 6 | |
| 7 | Usage: |
| 8 | ssh your-server \ |
| 9 | 'doas /usr/libexec/flua - -f -r -u 15.1' \ |
| 10 | < ./tools/scripts/bump-freebsd-manually |
| 11 | |
| 12 | Usage: 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 | |
| 23 | The host OS is always upgraded from FreeBSD-base, and packages always |
| 24 | from your pkg repo, unless -f is given, in which case from FreeBSD-ports. |
| 25 | ]] |
| 26 | |
| 27 | local BASE_REPO = "FreeBSD-base" |
| 28 | local PORTS_REPO = "pkg" |
| 29 | local OFFICIAL_PORTS_REPO = "FreeBSD-ports" |
| 30 | local BE_PREFIX = "patch" |
| 31 | local MIN_PKG = "2.8.4" |
| 32 | local EFI_LOADER = "/boot/loader.efi" |
| 33 | local EFI_BOOT_DIR = "/boot/efi/efi/boot" |
| 34 | |
| 35 | local dry, base_only, reboot, official_only, target = false, false, false, false, nil |
| 36 | |
| 37 | local function usage(code) |
| 38 | io.stderr:write(USAGE) |
| 39 | os.exit(code) |
| 40 | end |
| 41 | |
| 42 | local function die(msg) |
| 43 | io.stderr:write(msg, "\n") |
| 44 | os.exit(1) |
| 45 | end |
| 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. |
| 49 | local function shquote(s) |
| 50 | return "'" .. tostring(s):gsub("'", "'\\''") .. "'" |
| 51 | end |
| 52 | |
| 53 | local function run(cmd) |
| 54 | io.write("+ ", cmd, "\n") |
| 55 | local ok = os.execute(cmd) |
| 56 | if not ok then die("command failed: " .. cmd) end |
| 57 | end |
| 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. |
| 64 | local 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 |
| 74 | end |
| 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. |
| 79 | local 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 |
| 85 | end |
| 86 | |
| 87 | local function file_exists(path) |
| 88 | return os.execute("test -f " .. shquote(path)) and true or false |
| 89 | end |
| 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. |
| 93 | local 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 |
| 102 | end |
| 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. |
| 107 | do |
| 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 |
| 141 | end |
| 142 | |
| 143 | if official_only then PORTS_REPO = OFFICIAL_PORTS_REPO end |
| 144 | |
| 145 | if capture("id -u") ~= "0" then die("run as root (doas " .. arg[0] .. " ...)") end |
| 146 | if not capture("pkg which -q /usr/bin/uname"):find("FreeBSD%-runtime") then |
| 147 | die("not a pkgbase host") |
| 148 | end |
| 149 | |
| 150 | local installed |
| 151 | do |
| 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 |
| 160 | end |
| 161 | local running = capture("freebsd-version -r"):gsub("%-.*", "") |
| 162 | if not installed or installed == "" then |
| 163 | die("cannot read VERSION_ID from /etc/os-release") |
| 164 | end |
| 165 | |
| 166 | if 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 .. " ===") |
| 183 | else |
| 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 .. " ===") |
| 190 | end |
| 191 | run("freebsd-version -kru") |
| 192 | |
| 193 | -- One rollback point per day is enough: reuse a BE made earlier today. |
| 194 | local be |
| 195 | if 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 |
| 204 | end |
| 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. |
| 209 | run("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. |
| 214 | local PKG = "pkg" |
| 215 | local 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 |
| 218 | end |
| 219 | |
| 220 | if not target then |
| 221 | upgrade("-r " .. shquote(BASE_REPO) .. " -g 'FreeBSD-*'") |
| 222 | if not base_only then upgrade("-r " .. shquote(PORTS_REPO)) end |
| 223 | else |
| 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 |
| 257 | end |
| 258 | |
| 259 | if dry then os.exit(0) end |
| 260 | |
| 261 | print("=== Done ===") |
| 262 | run("freebsd-version -kru") |
| 263 | print("rollback: bectl activate " .. be .. " && shutdown -r now") |
| 264 | if reboot then |
| 265 | run('shutdown -r now "Rebooting after FreeBSD pkgbase upgrade"') |
| 266 | else |
| 267 | print("reboot to finish: shutdown -r now") |
| 268 | end |
| 269 |
a script to upgrade remote FreeBSD systems. - zfs only - base package only - UEFI boot only