blob: a357fa5a0ac69d188e636dd42c0ead151fe5ab07 [file] [log] [blame]
Lorenz Brun35fcf032023-06-29 04:15:58 +02001package update
2
3import (
4 "archive/zip"
5 "bytes"
6 "context"
Lorenz Brund14be0e2023-07-31 16:46:14 +02007 "debug/pe"
Lorenz Brun35fcf032023-06-29 04:15:58 +02008 "errors"
9 "fmt"
10 "io"
11 "net/http"
12 "os"
13 "path/filepath"
14 "regexp"
15 "strconv"
Lorenz Brund14be0e2023-07-31 16:46:14 +020016 "strings"
Lorenz Brun35fcf032023-06-29 04:15:58 +020017
18 "github.com/cenkalti/backoff/v4"
Lorenz Brund14be0e2023-07-31 16:46:14 +020019 "golang.org/x/sys/unix"
Lorenz Brun35fcf032023-06-29 04:15:58 +020020 "google.golang.org/grpc/codes"
21 "google.golang.org/grpc/status"
22
23 "source.monogon.dev/metropolis/node/build/mkimage/osimage"
24 "source.monogon.dev/metropolis/pkg/blockdev"
25 "source.monogon.dev/metropolis/pkg/efivarfs"
Tim Windelschmidt8e87a062023-07-31 01:33:10 +000026 "source.monogon.dev/metropolis/pkg/gpt"
Lorenz Brund14be0e2023-07-31 16:46:14 +020027 "source.monogon.dev/metropolis/pkg/kexec"
Lorenz Brun35fcf032023-06-29 04:15:58 +020028 "source.monogon.dev/metropolis/pkg/logtree"
29)
30
31// Service contains data and functionality to perform A/B updates on a
32// Metropolis node.
33type Service struct {
34 // Path to the mount point of the EFI System Partition (ESP).
35 ESPPath string
Tim Windelschmidt8e87a062023-07-31 01:33:10 +000036 // gpt.Partition of the ESP System Partition.
37 ESPPart *gpt.Partition
Lorenz Brun35fcf032023-06-29 04:15:58 +020038 // Partition number (1-based) of the ESP in the GPT partitions array.
39 ESPPartNumber uint32
Tim Windelschmidt8e87a062023-07-31 01:33:10 +000040
Lorenz Brun35fcf032023-06-29 04:15:58 +020041 // Logger service for the update service.
42 Logger logtree.LeveledLogger
43}
44
45type Slot int
46
47const (
48 SlotInvalid Slot = 0
49 SlotA Slot = 1
50 SlotB Slot = 2
51)
52
53// Other returns the "other" slot, i.e. returns slot A for B and B for A.
54// It returns SlotInvalid for any s which is not SlotA or SlotB.
55func (s Slot) Other() Slot {
56 switch s {
57 case SlotA:
58 return SlotB
59 case SlotB:
60 return SlotA
61 default:
62 return SlotInvalid
63 }
64}
65
66func (s Slot) String() string {
67 switch s {
68 case SlotA:
69 return "A"
70 case SlotB:
71 return "B"
72 default:
73 return "<invalid slot>"
74 }
75}
76
77func (s Slot) EFIBootPath() string {
78 switch s {
79 case SlotA:
80 return osimage.EFIBootAPath
81 case SlotB:
82 return osimage.EFIBootBPath
83 default:
84 return ""
85 }
86}
87
88var slotRegexp = regexp.MustCompile(`PARTLABEL=METROPOLIS-SYSTEM-([AB])`)
89
90// ProvideESP is a convenience function for providing information about the
91// ESP after the update service has been instantiated.
Tim Windelschmidt8e87a062023-07-31 01:33:10 +000092func (s *Service) ProvideESP(path string, partNum uint32, part *gpt.Partition) {
Lorenz Brun35fcf032023-06-29 04:15:58 +020093 s.ESPPath = path
94 s.ESPPartNumber = partNum
Tim Windelschmidt8e87a062023-07-31 01:33:10 +000095 s.ESPPart = part
Lorenz Brun35fcf032023-06-29 04:15:58 +020096}
97
98// CurrentlyRunningSlot returns the slot the current system is booted from.
99func (s *Service) CurrentlyRunningSlot() Slot {
100 cmdline, err := os.ReadFile("/proc/cmdline")
101 if err != nil {
102 return SlotInvalid
103 }
104 slotMatches := slotRegexp.FindStringSubmatch(string(cmdline))
105 if len(slotMatches) != 2 {
106 return SlotInvalid
107 }
108 switch slotMatches[1] {
109 case "A":
110 return SlotA
111 case "B":
112 return SlotB
113 default:
114 panic("unreachable")
115 }
116}
117
118var bootVarRegexp = regexp.MustCompile(`^Boot([0-9A-Fa-f]{4})$`)
119
120func (s *Service) getAllBootEntries() (map[int]*efivarfs.LoadOption, error) {
121 res := make(map[int]*efivarfs.LoadOption)
122 varNames, err := efivarfs.List(efivarfs.ScopeGlobal)
123 if err != nil {
124 return nil, fmt.Errorf("failed to list EFI variables: %w", err)
125 }
126 for _, varName := range varNames {
127 m := bootVarRegexp.FindStringSubmatch(varName)
128 if m == nil {
129 continue
130 }
131 idx, err := strconv.ParseUint(m[1], 16, 16)
132 if err != nil {
133 // This cannot be hit as all regexp matches are parseable.
134 panic(err)
135 }
136 e, err := efivarfs.GetBootEntry(int(idx))
137 if err != nil {
138 return nil, fmt.Errorf("failed to get boot entry %d: %w", idx, err)
139 }
140 res[int(idx)] = e
141 }
142 return res, nil
143}
144
145func (s *Service) getOrMakeBootEntry(existing map[int]*efivarfs.LoadOption, slot Slot) (int, error) {
Lorenz Brun32c5fb82023-08-03 17:37:56 +0200146 idx, ok := s.findBootEntry(existing, slot)
147 if ok {
148 return idx, nil
Lorenz Brun35fcf032023-06-29 04:15:58 +0200149 }
150 newEntry := &efivarfs.LoadOption{
151 Description: fmt.Sprintf("Metropolis Slot %s", slot),
152 FilePath: efivarfs.DevicePath{
153 &efivarfs.HardDrivePath{
Tim Windelschmidt8e87a062023-07-31 01:33:10 +0000154 PartitionNumber: s.ESPPartNumber,
155 PartitionStartBlock: s.ESPPart.FirstBlock,
156 PartitionSizeBlocks: s.ESPPart.SizeBlocks(),
Lorenz Brun35fcf032023-06-29 04:15:58 +0200157 PartitionMatch: efivarfs.PartitionGPT{
Tim Windelschmidt8e87a062023-07-31 01:33:10 +0000158 PartitionUUID: s.ESPPart.ID,
Lorenz Brun35fcf032023-06-29 04:15:58 +0200159 },
160 },
161 efivarfs.FilePath(slot.EFIBootPath()),
162 },
163 }
Lorenz Brund14be0e2023-07-31 16:46:14 +0200164 s.Logger.Infof("Recreated boot entry %s", newEntry.Description)
Lorenz Brun35fcf032023-06-29 04:15:58 +0200165 newIdx, err := efivarfs.AddBootEntry(newEntry)
166 if err == nil {
167 existing[newIdx] = newEntry
168 }
169 return newIdx, err
170}
171
Lorenz Brun32c5fb82023-08-03 17:37:56 +0200172func (s *Service) findBootEntry(existing map[int]*efivarfs.LoadOption, slot Slot) (int, bool) {
173 for idx, e := range existing {
174 if len(e.FilePath) != 2 {
175 // Not our entry
176 continue
177 }
178 switch p := e.FilePath[0].(type) {
179 case *efivarfs.HardDrivePath:
180 gptMatch, ok := p.PartitionMatch.(efivarfs.PartitionGPT)
181 if !(ok && gptMatch.PartitionUUID == s.ESPPart.ID) {
182 // Not related to our ESP
183 continue
184 }
185 default:
186 continue
187 }
188 switch p := e.FilePath[1].(type) {
189 case efivarfs.FilePath:
190 if string(p) == slot.EFIBootPath() {
191 return idx, true
192 }
193 default:
194 continue
195 }
196 }
197 return 0, false
198}
199
Lorenz Brun35fcf032023-06-29 04:15:58 +0200200// MarkBootSuccessful must be called after each boot if some implementation-
201// defined criteria for a successful boot are met. If an update has been
202// installed and booted and this function is called, the updated version is
203// marked as default. If an issue occurs during boot and so this function is
204// not called the old version will be started again on next boot.
205func (s *Service) MarkBootSuccessful() error {
206 if s.ESPPath == "" {
207 return errors.New("no ESP information provided to update service, cannot continue")
208 }
209 bootEntries, err := s.getAllBootEntries()
210 if err != nil {
211 return fmt.Errorf("while getting boot entries: %w", err)
212 }
213 aIdx, err := s.getOrMakeBootEntry(bootEntries, SlotA)
214 if err != nil {
215 return fmt.Errorf("while ensuring slot A boot entry: %w", err)
216 }
217 bIdx, err := s.getOrMakeBootEntry(bootEntries, SlotB)
218 if err != nil {
219 return fmt.Errorf("while ensuring slot B boot entry: %w", err)
220 }
221
222 activeSlot := s.CurrentlyRunningSlot()
223 firstSlot := SlotInvalid
224
225 ord, err := efivarfs.GetBootOrder()
226 if err != nil {
227 return fmt.Errorf("failed to get boot order: %w", err)
228 }
229
230 for _, e := range ord {
231 if int(e) == aIdx {
232 firstSlot = SlotA
233 break
234 }
235 if int(e) == bIdx {
236 firstSlot = SlotB
237 break
238 }
239 }
240
241 if firstSlot == SlotInvalid {
242 bootOrder := make(efivarfs.BootOrder, 2)
243 switch activeSlot {
244 case SlotA:
245 bootOrder[0], bootOrder[1] = uint16(aIdx), uint16(bIdx)
246 case SlotB:
247 bootOrder[0], bootOrder[1] = uint16(bIdx), uint16(aIdx)
248 default:
249 return fmt.Errorf("invalid active slot")
250 }
251 efivarfs.SetBootOrder(bootOrder)
252 s.Logger.Infof("Metropolis missing from boot order, recreated it")
253 } else if activeSlot != firstSlot {
254 var aPos, bPos int
255 for i, e := range ord {
256 if int(e) == aIdx {
257 aPos = i
258 }
259 if int(e) == bIdx {
260 bPos = i
261 }
262 }
263 // swap A and B slots in boot order
264 ord[aPos], ord[bPos] = ord[bPos], ord[aPos]
265 if err := efivarfs.SetBootOrder(ord); err != nil {
266 return fmt.Errorf("failed to set boot order to permanently switch slot: %w", err)
267 }
268 s.Logger.Infof("Permanently activated slot %v", activeSlot)
269 } else {
270 s.Logger.Infof("Normal boot from slot %v", activeSlot)
271 }
272
273 return nil
274}
275
276func openSystemSlot(slot Slot) (*blockdev.Device, error) {
277 switch slot {
278 case SlotA:
279 return blockdev.Open("/dev/system-a")
280 case SlotB:
281 return blockdev.Open("/dev/system-b")
282 default:
283 return nil, errors.New("invalid slot identifier given")
284 }
285}
286
287// InstallBundle installs the bundle at the given HTTP(S) URL into the currently
288// inactive slot and sets that slot to boot next. If it doesn't return an error,
289// a reboot boots into the new slot.
Lorenz Brund14be0e2023-07-31 16:46:14 +0200290func (s *Service) InstallBundle(ctx context.Context, bundleURL string, withKexec bool) error {
Lorenz Brun35fcf032023-06-29 04:15:58 +0200291 if s.ESPPath == "" {
292 return errors.New("no ESP information provided to update service, cannot continue")
293 }
294 // Download into a buffer as ZIP files cannot efficiently be read from
295 // HTTP in Go as the ReaderAt has no way of indicating continuous sections,
296 // thus a ton of small range requests would need to be used, causing
297 // a huge latency penalty as well as costing a lot of money on typical
298 // object storages. This should go away when we switch to a better bundle
299 // format which can be streamed.
300 var bundleRaw bytes.Buffer
301 b := backoff.NewExponentialBackOff()
302 err := backoff.Retry(func() error {
303 return s.tryDownloadBundle(ctx, bundleURL, &bundleRaw)
304 }, backoff.WithContext(b, ctx))
305 if err != nil {
306 return fmt.Errorf("error downloading Metropolis bundle: %v", err)
307 }
308 bundle, err := zip.NewReader(bytes.NewReader(bundleRaw.Bytes()), int64(bundleRaw.Len()))
309 if err != nil {
310 return fmt.Errorf("failed to open node bundle: %w", err)
311 }
312 efiPayload, err := bundle.Open("kernel_efi.efi")
313 if err != nil {
314 return fmt.Errorf("invalid bundle: %w", err)
315 }
316 defer efiPayload.Close()
317 systemImage, err := bundle.Open("verity_rootfs.img")
318 if err != nil {
319 return fmt.Errorf("invalid bundle: %w", err)
320 }
321 defer systemImage.Close()
322 activeSlot := s.CurrentlyRunningSlot()
323 if activeSlot == SlotInvalid {
324 return errors.New("unable to determine active slot, cannot continue")
325 }
326 targetSlot := activeSlot.Other()
327
328 bootEntries, err := s.getAllBootEntries()
329 if err != nil {
330 return fmt.Errorf("while getting boot entries: %w", err)
331 }
332 targetSlotBootEntryIdx, err := s.getOrMakeBootEntry(bootEntries, targetSlot)
333 if err != nil {
334 return fmt.Errorf("while ensuring target slot boot entry: %w", err)
335 }
336 targetSlotBootEntry := bootEntries[targetSlotBootEntryIdx]
337
338 // Disable boot entry while the corresponding slot is being modified.
339 targetSlotBootEntry.Inactive = true
340 if err := efivarfs.SetBootEntry(targetSlotBootEntryIdx, targetSlotBootEntry); err != nil {
341 return fmt.Errorf("failed setting boot entry %d inactive: %w", targetSlotBootEntryIdx, err)
342 }
343
344 systemPart, err := openSystemSlot(targetSlot)
345 if err != nil {
346 return status.Errorf(codes.Internal, "Inactive system slot unavailable: %v", err)
347 }
348 defer systemPart.Close()
349 if _, err := io.Copy(blockdev.NewRWS(systemPart), systemImage); err != nil {
350 return status.Errorf(codes.Unavailable, "Failed to copy system image: %v", err)
351 }
352
353 bootFile, err := os.Create(filepath.Join(s.ESPPath, targetSlot.EFIBootPath()))
354 if err != nil {
355 return fmt.Errorf("failed to open boot file: %w", err)
356 }
357 defer bootFile.Close()
358 if _, err := io.Copy(bootFile, efiPayload); err != nil {
359 return fmt.Errorf("failed to write boot file: %w", err)
360 }
361
362 // Reenable target slot boot entry after boot and system have been written
363 // fully. The slot should now be bootable again.
364 targetSlotBootEntry.Inactive = false
365 if err := efivarfs.SetBootEntry(targetSlotBootEntryIdx, targetSlotBootEntry); err != nil {
366 return fmt.Errorf("failed setting boot entry %d active: %w", targetSlotBootEntryIdx, err)
367 }
368
Lorenz Brund14be0e2023-07-31 16:46:14 +0200369 if withKexec {
370 if err := s.stageKexec(bootFile, targetSlot); err != nil {
371 return fmt.Errorf("while kexec staging: %w", err)
372 }
373 } else {
374 if err := efivarfs.SetBootNext(uint16(targetSlotBootEntryIdx)); err != nil {
375 return fmt.Errorf("failed to set BootNext variable: %w", err)
376 }
Lorenz Brun35fcf032023-06-29 04:15:58 +0200377 }
378
379 return nil
380}
381
382func (*Service) tryDownloadBundle(ctx context.Context, bundleURL string, bundleRaw *bytes.Buffer) error {
383 bundleReq, err := http.NewRequestWithContext(ctx, "GET", bundleURL, nil)
384 bundleRes, err := http.DefaultClient.Do(bundleReq)
385 if err != nil {
386 return fmt.Errorf("HTTP request failed: %w", err)
387 }
388 defer bundleRes.Body.Close()
389 switch bundleRes.StatusCode {
390 case http.StatusTooEarly, http.StatusTooManyRequests,
391 http.StatusInternalServerError, http.StatusBadGateway,
392 http.StatusServiceUnavailable, http.StatusGatewayTimeout:
393 return fmt.Errorf("HTTP error %d", bundleRes.StatusCode)
394 default:
395 // Non-standard code range used for proxy-related issue by various
396 // vendors. Treat as non-permanent error.
397 if bundleRes.StatusCode >= 520 && bundleRes.StatusCode < 599 {
398 return fmt.Errorf("HTTP error %d", bundleRes.StatusCode)
399 }
400 if bundleRes.StatusCode != 200 {
401 return backoff.Permanent(fmt.Errorf("HTTP error %d", bundleRes.StatusCode))
402 }
403 }
404 if _, err := bundleRaw.ReadFrom(bundleRes.Body); err != nil {
405 bundleRaw.Reset()
406 return err
407 }
408 return nil
409}
Lorenz Brund14be0e2023-07-31 16:46:14 +0200410
411// newMemfile creates a new file which is not located on a specific filesystem,
412// but is instead backed by anonymous memory.
413func newMemfile(name string, flags int) (*os.File, error) {
414 fd, err := unix.MemfdCreate(name, flags)
415 if err != nil {
416 return nil, fmt.Errorf("memfd_create: %w", err)
417 }
418 return os.NewFile(uintptr(fd), name), nil
419}
420
421// stageKexec stages the kernel, command line and initramfs if available for
422// a future kexec. It extracts the relevant data from the EFI boot executable.
423func (s *Service) stageKexec(bootFile io.ReaderAt, targetSlot Slot) error {
424 bootPE, err := pe.NewFile(bootFile)
425 if err != nil {
426 return fmt.Errorf("unable to open bootFile as PE: %w", err)
427 }
428 var cmdlineRaw []byte
429 cmdlineSection := bootPE.Section(".cmdline")
430 if cmdlineSection == nil {
431 return fmt.Errorf("no .cmdline section in boot PE")
432 }
433 cmdlineRaw, err = cmdlineSection.Data()
434 if err != nil {
435 return fmt.Errorf("while reading .cmdline PE section: %w", err)
436 }
437 cmdline := string(bytes.TrimRight(cmdlineRaw, "\x00"))
438 cmdline = strings.ReplaceAll(cmdline, "METROPOLIS-SYSTEM-X", fmt.Sprintf("METROPOLIS-SYSTEM-%s", targetSlot))
439 kernelFile, err := newMemfile("kernel", 0)
440 if err != nil {
441 return fmt.Errorf("failed to create kernel memfile: %w", err)
442 }
443 defer kernelFile.Close()
444 kernelSection := bootPE.Section(".linux")
445 if kernelSection == nil {
446 return fmt.Errorf("no .linux section in boot PE")
447 }
448 if _, err := io.Copy(kernelFile, kernelSection.Open()); err != nil {
449 return fmt.Errorf("while copying .linux PE section: %w", err)
450 }
451
452 initramfsSection := bootPE.Section(".initrd")
453 var initramfsFile *os.File
454 if initramfsSection != nil && initramfsSection.Size > 0 {
455 initramfsFile, err = newMemfile("initramfs", 0)
456 if err != nil {
457 return fmt.Errorf("failed to create initramfs memfile: %w", err)
458 }
459 defer initramfsFile.Close()
460 if _, err := io.Copy(initramfsFile, initramfsSection.Open()); err != nil {
461 return fmt.Errorf("while copying .initrd PE section: %w", err)
462 }
463 }
464 if err := kexec.FileLoad(kernelFile, initramfsFile, cmdline); err != nil {
465 return fmt.Errorf("while staging new kexec kernel: %w", err)
466 }
467 return nil
468}