blob: 75fe752096d08600a3c9899851a0eb6010c937e4 [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 {
Lorenz Brun95636732023-08-07 16:59:40 +0200138 s.Logger.Warningf("Unable to get boot entry %d, skipping: %v", idx, err)
139 continue
Lorenz Brun35fcf032023-06-29 04:15:58 +0200140 }
141 res[int(idx)] = e
142 }
143 return res, nil
144}
145
146func (s *Service) getOrMakeBootEntry(existing map[int]*efivarfs.LoadOption, slot Slot) (int, error) {
Lorenz Brun32c5fb82023-08-03 17:37:56 +0200147 idx, ok := s.findBootEntry(existing, slot)
148 if ok {
149 return idx, nil
Lorenz Brun35fcf032023-06-29 04:15:58 +0200150 }
151 newEntry := &efivarfs.LoadOption{
152 Description: fmt.Sprintf("Metropolis Slot %s", slot),
153 FilePath: efivarfs.DevicePath{
154 &efivarfs.HardDrivePath{
Tim Windelschmidt8e87a062023-07-31 01:33:10 +0000155 PartitionNumber: s.ESPPartNumber,
156 PartitionStartBlock: s.ESPPart.FirstBlock,
157 PartitionSizeBlocks: s.ESPPart.SizeBlocks(),
Lorenz Brun35fcf032023-06-29 04:15:58 +0200158 PartitionMatch: efivarfs.PartitionGPT{
Tim Windelschmidt8e87a062023-07-31 01:33:10 +0000159 PartitionUUID: s.ESPPart.ID,
Lorenz Brun35fcf032023-06-29 04:15:58 +0200160 },
161 },
162 efivarfs.FilePath(slot.EFIBootPath()),
163 },
164 }
Lorenz Brund14be0e2023-07-31 16:46:14 +0200165 s.Logger.Infof("Recreated boot entry %s", newEntry.Description)
Lorenz Brun35fcf032023-06-29 04:15:58 +0200166 newIdx, err := efivarfs.AddBootEntry(newEntry)
167 if err == nil {
168 existing[newIdx] = newEntry
169 }
170 return newIdx, err
171}
172
Lorenz Brun32c5fb82023-08-03 17:37:56 +0200173func (s *Service) findBootEntry(existing map[int]*efivarfs.LoadOption, slot Slot) (int, bool) {
174 for idx, e := range existing {
175 if len(e.FilePath) != 2 {
176 // Not our entry
177 continue
178 }
179 switch p := e.FilePath[0].(type) {
180 case *efivarfs.HardDrivePath:
181 gptMatch, ok := p.PartitionMatch.(efivarfs.PartitionGPT)
182 if !(ok && gptMatch.PartitionUUID == s.ESPPart.ID) {
183 // Not related to our ESP
184 continue
185 }
186 default:
187 continue
188 }
189 switch p := e.FilePath[1].(type) {
190 case efivarfs.FilePath:
191 if string(p) == slot.EFIBootPath() {
192 return idx, true
193 }
194 default:
195 continue
196 }
197 }
198 return 0, false
199}
200
Lorenz Brun35fcf032023-06-29 04:15:58 +0200201// MarkBootSuccessful must be called after each boot if some implementation-
202// defined criteria for a successful boot are met. If an update has been
203// installed and booted and this function is called, the updated version is
204// marked as default. If an issue occurs during boot and so this function is
205// not called the old version will be started again on next boot.
206func (s *Service) MarkBootSuccessful() error {
207 if s.ESPPath == "" {
208 return errors.New("no ESP information provided to update service, cannot continue")
209 }
210 bootEntries, err := s.getAllBootEntries()
211 if err != nil {
212 return fmt.Errorf("while getting boot entries: %w", err)
213 }
214 aIdx, err := s.getOrMakeBootEntry(bootEntries, SlotA)
215 if err != nil {
216 return fmt.Errorf("while ensuring slot A boot entry: %w", err)
217 }
218 bIdx, err := s.getOrMakeBootEntry(bootEntries, SlotB)
219 if err != nil {
220 return fmt.Errorf("while ensuring slot B boot entry: %w", err)
221 }
222
223 activeSlot := s.CurrentlyRunningSlot()
224 firstSlot := SlotInvalid
225
226 ord, err := efivarfs.GetBootOrder()
227 if err != nil {
228 return fmt.Errorf("failed to get boot order: %w", err)
229 }
230
231 for _, e := range ord {
232 if int(e) == aIdx {
233 firstSlot = SlotA
234 break
235 }
236 if int(e) == bIdx {
237 firstSlot = SlotB
238 break
239 }
240 }
241
242 if firstSlot == SlotInvalid {
243 bootOrder := make(efivarfs.BootOrder, 2)
244 switch activeSlot {
245 case SlotA:
246 bootOrder[0], bootOrder[1] = uint16(aIdx), uint16(bIdx)
247 case SlotB:
248 bootOrder[0], bootOrder[1] = uint16(bIdx), uint16(aIdx)
249 default:
250 return fmt.Errorf("invalid active slot")
251 }
252 efivarfs.SetBootOrder(bootOrder)
253 s.Logger.Infof("Metropolis missing from boot order, recreated it")
254 } else if activeSlot != firstSlot {
255 var aPos, bPos int
256 for i, e := range ord {
257 if int(e) == aIdx {
258 aPos = i
259 }
260 if int(e) == bIdx {
261 bPos = i
262 }
263 }
264 // swap A and B slots in boot order
265 ord[aPos], ord[bPos] = ord[bPos], ord[aPos]
266 if err := efivarfs.SetBootOrder(ord); err != nil {
267 return fmt.Errorf("failed to set boot order to permanently switch slot: %w", err)
268 }
269 s.Logger.Infof("Permanently activated slot %v", activeSlot)
270 } else {
271 s.Logger.Infof("Normal boot from slot %v", activeSlot)
272 }
273
274 return nil
275}
276
277func openSystemSlot(slot Slot) (*blockdev.Device, error) {
278 switch slot {
279 case SlotA:
280 return blockdev.Open("/dev/system-a")
281 case SlotB:
282 return blockdev.Open("/dev/system-b")
283 default:
284 return nil, errors.New("invalid slot identifier given")
285 }
286}
287
288// InstallBundle installs the bundle at the given HTTP(S) URL into the currently
289// inactive slot and sets that slot to boot next. If it doesn't return an error,
290// a reboot boots into the new slot.
Lorenz Brund14be0e2023-07-31 16:46:14 +0200291func (s *Service) InstallBundle(ctx context.Context, bundleURL string, withKexec bool) error {
Lorenz Brun35fcf032023-06-29 04:15:58 +0200292 if s.ESPPath == "" {
293 return errors.New("no ESP information provided to update service, cannot continue")
294 }
295 // Download into a buffer as ZIP files cannot efficiently be read from
296 // HTTP in Go as the ReaderAt has no way of indicating continuous sections,
297 // thus a ton of small range requests would need to be used, causing
298 // a huge latency penalty as well as costing a lot of money on typical
299 // object storages. This should go away when we switch to a better bundle
300 // format which can be streamed.
301 var bundleRaw bytes.Buffer
302 b := backoff.NewExponentialBackOff()
303 err := backoff.Retry(func() error {
304 return s.tryDownloadBundle(ctx, bundleURL, &bundleRaw)
305 }, backoff.WithContext(b, ctx))
306 if err != nil {
307 return fmt.Errorf("error downloading Metropolis bundle: %v", err)
308 }
309 bundle, err := zip.NewReader(bytes.NewReader(bundleRaw.Bytes()), int64(bundleRaw.Len()))
310 if err != nil {
311 return fmt.Errorf("failed to open node bundle: %w", err)
312 }
313 efiPayload, err := bundle.Open("kernel_efi.efi")
314 if err != nil {
315 return fmt.Errorf("invalid bundle: %w", err)
316 }
317 defer efiPayload.Close()
318 systemImage, err := bundle.Open("verity_rootfs.img")
319 if err != nil {
320 return fmt.Errorf("invalid bundle: %w", err)
321 }
322 defer systemImage.Close()
323 activeSlot := s.CurrentlyRunningSlot()
324 if activeSlot == SlotInvalid {
325 return errors.New("unable to determine active slot, cannot continue")
326 }
327 targetSlot := activeSlot.Other()
328
329 bootEntries, err := s.getAllBootEntries()
330 if err != nil {
331 return fmt.Errorf("while getting boot entries: %w", err)
332 }
333 targetSlotBootEntryIdx, err := s.getOrMakeBootEntry(bootEntries, targetSlot)
334 if err != nil {
335 return fmt.Errorf("while ensuring target slot boot entry: %w", err)
336 }
337 targetSlotBootEntry := bootEntries[targetSlotBootEntryIdx]
338
339 // Disable boot entry while the corresponding slot is being modified.
340 targetSlotBootEntry.Inactive = true
341 if err := efivarfs.SetBootEntry(targetSlotBootEntryIdx, targetSlotBootEntry); err != nil {
342 return fmt.Errorf("failed setting boot entry %d inactive: %w", targetSlotBootEntryIdx, err)
343 }
344
345 systemPart, err := openSystemSlot(targetSlot)
346 if err != nil {
347 return status.Errorf(codes.Internal, "Inactive system slot unavailable: %v", err)
348 }
349 defer systemPart.Close()
350 if _, err := io.Copy(blockdev.NewRWS(systemPart), systemImage); err != nil {
351 return status.Errorf(codes.Unavailable, "Failed to copy system image: %v", err)
352 }
353
354 bootFile, err := os.Create(filepath.Join(s.ESPPath, targetSlot.EFIBootPath()))
355 if err != nil {
356 return fmt.Errorf("failed to open boot file: %w", err)
357 }
358 defer bootFile.Close()
359 if _, err := io.Copy(bootFile, efiPayload); err != nil {
360 return fmt.Errorf("failed to write boot file: %w", err)
361 }
362
363 // Reenable target slot boot entry after boot and system have been written
364 // fully. The slot should now be bootable again.
365 targetSlotBootEntry.Inactive = false
366 if err := efivarfs.SetBootEntry(targetSlotBootEntryIdx, targetSlotBootEntry); err != nil {
367 return fmt.Errorf("failed setting boot entry %d active: %w", targetSlotBootEntryIdx, err)
368 }
369
Lorenz Brund14be0e2023-07-31 16:46:14 +0200370 if withKexec {
371 if err := s.stageKexec(bootFile, targetSlot); err != nil {
372 return fmt.Errorf("while kexec staging: %w", err)
373 }
374 } else {
375 if err := efivarfs.SetBootNext(uint16(targetSlotBootEntryIdx)); err != nil {
376 return fmt.Errorf("failed to set BootNext variable: %w", err)
377 }
Lorenz Brun35fcf032023-06-29 04:15:58 +0200378 }
379
380 return nil
381}
382
383func (*Service) tryDownloadBundle(ctx context.Context, bundleURL string, bundleRaw *bytes.Buffer) error {
384 bundleReq, err := http.NewRequestWithContext(ctx, "GET", bundleURL, nil)
385 bundleRes, err := http.DefaultClient.Do(bundleReq)
386 if err != nil {
387 return fmt.Errorf("HTTP request failed: %w", err)
388 }
389 defer bundleRes.Body.Close()
390 switch bundleRes.StatusCode {
391 case http.StatusTooEarly, http.StatusTooManyRequests,
392 http.StatusInternalServerError, http.StatusBadGateway,
393 http.StatusServiceUnavailable, http.StatusGatewayTimeout:
394 return fmt.Errorf("HTTP error %d", bundleRes.StatusCode)
395 default:
396 // Non-standard code range used for proxy-related issue by various
397 // vendors. Treat as non-permanent error.
398 if bundleRes.StatusCode >= 520 && bundleRes.StatusCode < 599 {
399 return fmt.Errorf("HTTP error %d", bundleRes.StatusCode)
400 }
401 if bundleRes.StatusCode != 200 {
402 return backoff.Permanent(fmt.Errorf("HTTP error %d", bundleRes.StatusCode))
403 }
404 }
405 if _, err := bundleRaw.ReadFrom(bundleRes.Body); err != nil {
406 bundleRaw.Reset()
407 return err
408 }
409 return nil
410}
Lorenz Brund14be0e2023-07-31 16:46:14 +0200411
412// newMemfile creates a new file which is not located on a specific filesystem,
413// but is instead backed by anonymous memory.
414func newMemfile(name string, flags int) (*os.File, error) {
415 fd, err := unix.MemfdCreate(name, flags)
416 if err != nil {
417 return nil, fmt.Errorf("memfd_create: %w", err)
418 }
419 return os.NewFile(uintptr(fd), name), nil
420}
421
422// stageKexec stages the kernel, command line and initramfs if available for
423// a future kexec. It extracts the relevant data from the EFI boot executable.
424func (s *Service) stageKexec(bootFile io.ReaderAt, targetSlot Slot) error {
425 bootPE, err := pe.NewFile(bootFile)
426 if err != nil {
427 return fmt.Errorf("unable to open bootFile as PE: %w", err)
428 }
429 var cmdlineRaw []byte
430 cmdlineSection := bootPE.Section(".cmdline")
431 if cmdlineSection == nil {
432 return fmt.Errorf("no .cmdline section in boot PE")
433 }
434 cmdlineRaw, err = cmdlineSection.Data()
435 if err != nil {
436 return fmt.Errorf("while reading .cmdline PE section: %w", err)
437 }
438 cmdline := string(bytes.TrimRight(cmdlineRaw, "\x00"))
439 cmdline = strings.ReplaceAll(cmdline, "METROPOLIS-SYSTEM-X", fmt.Sprintf("METROPOLIS-SYSTEM-%s", targetSlot))
440 kernelFile, err := newMemfile("kernel", 0)
441 if err != nil {
442 return fmt.Errorf("failed to create kernel memfile: %w", err)
443 }
444 defer kernelFile.Close()
445 kernelSection := bootPE.Section(".linux")
446 if kernelSection == nil {
447 return fmt.Errorf("no .linux section in boot PE")
448 }
449 if _, err := io.Copy(kernelFile, kernelSection.Open()); err != nil {
450 return fmt.Errorf("while copying .linux PE section: %w", err)
451 }
452
453 initramfsSection := bootPE.Section(".initrd")
454 var initramfsFile *os.File
455 if initramfsSection != nil && initramfsSection.Size > 0 {
456 initramfsFile, err = newMemfile("initramfs", 0)
457 if err != nil {
458 return fmt.Errorf("failed to create initramfs memfile: %w", err)
459 }
460 defer initramfsFile.Close()
461 if _, err := io.Copy(initramfsFile, initramfsSection.Open()); err != nil {
462 return fmt.Errorf("while copying .initrd PE section: %w", err)
463 }
464 }
465 if err := kexec.FileLoad(kernelFile, initramfsFile, cmdline); err != nil {
466 return fmt.Errorf("while staging new kexec kernel: %w", err)
467 }
468 return nil
469}